From e5305eb31a4aafe5a62debbbb458cdf701dde489 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Thu, 30 Oct 2025 15:03:41 -0500 Subject: [PATCH 1/7] feat: enabled the token id page redesign --- src/__tests__/pages/token/[tokenId].test.tsx | 107 --------- src/app/token/[tokenId]/PageClient.tsx | 162 +------------ .../token/[tokenId]/PageClientRedesign.tsx | 13 - src/app/token/[tokenId]/getTokenInfo.ts | 224 ------------------ src/app/token/[tokenId]/page.tsx | 10 +- .../[tokenId]/redesign/TokenIdHeader.tsx | 2 + .../token/[tokenId]/redesign/TokenIdTabs.tsx | 2 + 7 files changed, 15 insertions(+), 505 deletions(-) delete mode 100644 src/__tests__/pages/token/[tokenId].test.tsx delete mode 100644 src/app/token/[tokenId]/PageClientRedesign.tsx delete mode 100644 src/app/token/[tokenId]/getTokenInfo.ts diff --git a/src/__tests__/pages/token/[tokenId].test.tsx b/src/__tests__/pages/token/[tokenId].test.tsx deleted file mode 100644 index 4786a640a..000000000 --- a/src/__tests__/pages/token/[tokenId].test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import '@testing-library/jest-dom'; - -import { getTokenInfo } from '../../../app/token/[tokenId]/getTokenInfo'; - -global.fetch = jest.fn(); -const fetch = global.fetch as jest.MockedFunction; - -jest.mock('../../../api/getApiClient', () => ({ - getApiClient: jest.fn(), -})); - -jest.mock('@hirosystems/token-metadata-api-client', () => ({ - Configuration: jest.fn(), -})); - -console.error = jest.fn(); - -describe('getTokenInfo', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('skips fetching token for custom api', async () => { - const tokenId = 'token1'; - const chain = 'mainnet'; - const api = 'custom'; - - const result = await getTokenInfo(tokenId, chain, api); - - expect(result).toEqual({}); - expect(console.error).toBeCalledWith(new Error('cannot fetch token info for this request')); - }); - - it('skips fetching token if missing from tokenMetadataApi', async () => { - const tokenId = 'token1'; - const chain = 'mainnet'; - - fetch.mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce({ name: null, symbol: 'SYMBOL' }), - }); - - const result = await getTokenInfo(tokenId, chain); - - expect(result).toEqual({}); - expect(console.error).toBeCalledWith(new Error('token not found')); - }); - - it('returns token info', async () => { - const tokenId = 'token1'; - const chain = 'mainnet'; - - fetch - .mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce({ name: 'NAME', symbol: 'SYMBOL' }), - }) - .mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce({ coins: [{ id: 'ID', symbol: 'SYMBOL' }] }), - }) - .mockResolvedValueOnce({ - json: jest.fn().mockResolvedValueOnce({ name: 'NAME', symbol: 'SYMBOL' }), - }); - - const result = await getTokenInfo(tokenId, chain); - - expect(result).toEqual({ - basic: { - name: 'NAME', - symbol: 'SYMBOL', - totalSupply: null, - circulatingSupply: null, - }, - extended: { - categories: [], - circulatingSupply: null, - currentPrice: null, - currentPriceInBtc: null, - links: { - announcements: [], - blockchain: [], - chat: [], - forums: [], - repos: [], - websites: [], - social: [], - }, - marketCap: null, - priceChangePercentage24h: null, - tradingVolume24h: null, - tradingVolumeChangePercentage24h: null, - marketCapRank: null, - priceInBtcChangePercentage24h: null, - developerData: { - closed_issues: null, - code_additions_deletions_4_weeks: null, - commit_count_4_weeks: null, - forks: null, - last_4_weeks_commit_activity_series: null, - pull_request_contributors: null, - pull_requests_merged: null, - stars: null, - subscribers: null, - total_issues: null, - }, - }, - }); - }); -}); diff --git a/src/app/token/[tokenId]/PageClient.tsx b/src/app/token/[tokenId]/PageClient.tsx index defe0a0d6..1e81135f0 100644 --- a/src/app/token/[tokenId]/PageClient.tsx +++ b/src/app/token/[tokenId]/PageClient.tsx @@ -1,159 +1,13 @@ -'use client'; +import { Stack } from '@chakra-ui/react'; -import { TokenInfoProps } from '@/app/token/[tokenId]/types'; -import { getHasSBTCInName, getIsSBTC } from '@/app/tokens/utils'; -import { useIsRedesignUrl } from '@/common/utils/url-utils'; -import { Link } from '@/ui/Link'; -import { Box, Flex, Icon, Image, Stack } from '@chakra-ui/react'; -import { SealCheck, Warning } from '@phosphor-icons/react'; -import { ReactNode, useMemo } from 'react'; - -import { Sip10Disclaimer } from '../../../common/components/Sip10Disclaimer'; -import { Tag } from '../../../components/ui/tag'; -import { TagLabel } from '../../../ui/TagLabel'; -import { Text } from '../../../ui/Text'; -import { PageTitle } from '../../_components/PageTitle'; -import TokenIdPageRedesign from './PageClientRedesign'; -import { TokenTabs } from './Tabs'; -import { TokenInfo } from './TokenInfo'; -import { RISKY_TOKENS, VERIFIED_TOKENS, legitsBTCDerivatives } from './consts'; - -const WarningMessage = ({ text }: { text: string | ReactNode }) => { - return ( - - - - - - - Warning: - - - - - {text} - - - - ); -}; - -export default function TokenIdPage({ - tokenId, - tokenInfo, -}: { - tokenId: string; - tokenInfo: TokenInfoProps; -}) { - const isRedesignUrl = useIsRedesignUrl(); - - if (!tokenInfo?.basic) throw new Error('Could not find token info'); - - const { name, symbol, imageUri } = tokenInfo.basic || {}; - const categories = tokenInfo?.extended?.categories || []; - const hasSBTCInName = getHasSBTCInName(name ?? '', symbol ?? ''); - const isSBTC = getIsSBTC(tokenId); - const isRisky = RISKY_TOKENS.includes(tokenId); - - const warningMessage = useMemo(() => { - if (isRisky) { - return ( - - ); - } - - if (hasSBTCInName && !isSBTC && !legitsBTCDerivatives.includes(tokenId)) { - return ( - - This is not{' '} - - the official sBTC token - {' '} - and may be a scam. Engaging with unverified tokens could result in loss of funds. - - } - /> - ); - } - - return null; - }, [hasSBTCInName, isRisky, isSBTC, tokenId]); - - if (isRedesignUrl) { - return ; - } +import { TokenIdHeader } from './redesign/TokenIdHeader'; +import { TokenIdTabs } from './redesign/TokenIdTabs'; +export default function TokenIdPageRedesign() { return ( - <> - - {!!categories.length && ( - - {categories.map(category => ( - - {category} - - ))} - - )} - - {imageUri && {name} - - {name} ({symbol}) - - {(isSBTC || VERIFIED_TOKENS.includes(tokenId)) && ( - - - - - - Verified - - - )} - {!!warningMessage && ( - - - - - - Unverified - - - )} - - - {warningMessage} - - - - + + + + ); } diff --git a/src/app/token/[tokenId]/PageClientRedesign.tsx b/src/app/token/[tokenId]/PageClientRedesign.tsx deleted file mode 100644 index 1e81135f0..000000000 --- a/src/app/token/[tokenId]/PageClientRedesign.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Stack } from '@chakra-ui/react'; - -import { TokenIdHeader } from './redesign/TokenIdHeader'; -import { TokenIdTabs } from './redesign/TokenIdTabs'; - -export default function TokenIdPageRedesign() { - return ( - - - - - ); -} diff --git a/src/app/token/[tokenId]/getTokenInfo.ts b/src/app/token/[tokenId]/getTokenInfo.ts deleted file mode 100644 index 8cd86963c..000000000 --- a/src/app/token/[tokenId]/getTokenInfo.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { ContractResponse } from '@/common/types/tx'; -import { FtMetadataResponse } from '@hirosystems/token-metadata-api-client'; - -import { FungibleTokenHolderList } from '@stacks/stacks-blockchain-api-types'; - -import { getIsSBTC } from '../../../app/tokens/utils'; -import { LUNAR_CRUSH_API_KEY } from '../../../common/constants/env'; -import { LunarCrushCoin } from '../../../common/types/lunarCrush'; -import { getApiUrl } from '../../../common/utils/network-utils'; -import { getFtDecimalAdjustedBalance } from '../../../common/utils/utils'; -import { BasicTokenInfo, DeveloperData, TokenInfoProps, TokenLinks } from './types'; - -async function getTokenInfoFromLunarCrush(tokenId: string): Promise { - try { - return await ( - await fetch(`https://lunarcrush.com/api4/public/coins/${tokenId}/v1`, { - cache: 'default', - next: { revalidate: 60 * 10 }, // Revalidate every 10 minutes - headers: { - Authorization: `Bearer ${LUNAR_CRUSH_API_KEY}`, - }, - }) - ).json(); - } catch (error) { - console.error(error); - } -} - -async function getCirculatingSupplyFromHoldersEndpoint(apiUrl: string, tokenId: string) { - const contractInfoResponse = await fetch(`${apiUrl}/extended/v1/contract/${tokenId}`); - if (!contractInfoResponse.ok) { - console.error('Failed to fetch contract info'); - return null; - } - const contractInfo: ContractResponse = await contractInfoResponse.json(); - if (!contractInfo.abi) { - console.error('No ABI found for token'); - return null; - } - const abi = JSON.parse(contractInfo.abi); - if (!abi?.fungible_tokens || abi.fungible_tokens.length === 0) { - console.error('No fungible tokens found in ABI'); - return null; - } - const ftName = abi.fungible_tokens[0].name; - const fullyQualifiedTokenId = `${tokenId}::${ftName}`; - const holdersResponse = await fetch( - `${apiUrl}/extended/v1/tokens/ft/${fullyQualifiedTokenId}/holders` - ); - if (!holdersResponse.ok) { - console.error('Failed to fetch holders info'); - return null; - } - const holdersInfo: FungibleTokenHolderList = await holdersResponse.json(); - if (!holdersInfo?.total_supply) { - console.error('No total supply found in holders info'); - return null; - } - const holdersCirculatingSupply = holdersInfo.total_supply; - return holdersCirculatingSupply; -} - -async function getBasicTokenInfoFromStacksApi( - tokenId: string, - chain: string, - api?: string -): Promise { - const isCustomApi = !!api; - - try { - const apiUrl = isCustomApi ? api : getApiUrl(chain); - if (!tokenId || !apiUrl || isCustomApi) { - throw new Error('Unable to fetch token info for this request'); - } - - const tokenMetadataResponse = await fetch(`${apiUrl}/metadata/v1/ft/${tokenId}`); - const tokenMetadata: FtMetadataResponse = await tokenMetadataResponse.json(); - - const tokenName = tokenMetadata?.name; - const tokenSymbol = tokenMetadata?.symbol; - const tokenDecimals = tokenMetadata?.decimals; - - if (!tokenName || !tokenSymbol) { - throw new Error('token not found'); - } - - const holdersCirculatingSupply = await getCirculatingSupplyFromHoldersEndpoint(apiUrl, tokenId); - - return { - name: tokenMetadata?.metadata?.name || tokenName, - symbol: tokenSymbol, - totalSupply: - tokenMetadata?.total_supply && tokenDecimals - ? getFtDecimalAdjustedBalance(tokenMetadata?.total_supply, tokenDecimals) - : null, - circulatingSupply: holdersCirculatingSupply - ? getFtDecimalAdjustedBalance(holdersCirculatingSupply, tokenDecimals || 0) - : null, - imageUri: tokenMetadata?.image_uri, - }; - } catch (error) { - console.error(error); - } -} - -async function getDetailedTokenInfoFromLunarCrush(tokenId: string, basicTokenInfo: BasicTokenInfo) { - try { - const tokenInfoResponse = await getTokenInfoFromLunarCrush(tokenId); - if (!tokenInfoResponse || tokenInfoResponse?.error) { - console.error('token not found in LunarCrush'); - return { - basic: basicTokenInfo, - }; - } - - const isSBTC = getIsSBTC(tokenId); - - const name = tokenInfoResponse?.data?.name || basicTokenInfo.name || null; - const symbol = basicTokenInfo.symbol || tokenInfoResponse?.data?.symbol || null; - const categories: string[] = []; - - const totalSupply = basicTokenInfo.totalSupply || null; - const circulatingSupplyFromBasicTokenInfo = basicTokenInfo.circulatingSupply || null; - const circulatingSupply = isSBTC - ? circulatingSupplyFromBasicTokenInfo // LunarCrush is returning an incorrect circulating supply for SBTC. Use the circulating supply from the holders endpoint on Stacks API instead. - : tokenInfoResponse?.data?.circulating_supply || circulatingSupplyFromBasicTokenInfo || null; - const imageUri = basicTokenInfo.imageUri || undefined; - - const currentPrice = tokenInfoResponse?.data?.price || null; - const currentPriceInBtc = tokenInfoResponse?.data?.price_btc || null; - const priceChangePercentage24h = tokenInfoResponse?.data?.percent_change_24h || null; - const priceInBtcChangePercentage24h = null; - - const marketCap = tokenInfoResponse?.data?.market_cap || null; - const tradingVolume24h = tokenInfoResponse?.data?.volume_24h || null; - const tradingVolumeChangePercentage24h = null; - const developerData: DeveloperData = { - forks: null, - stars: null, - subscribers: null, - total_issues: null, - closed_issues: null, - pull_requests_merged: null, - pull_request_contributors: null, - code_additions_deletions_4_weeks: null, - commit_count_4_weeks: null, - last_4_weeks_commit_activity_series: null, - }; - - const links: TokenLinks = { - websites: [], - blockchain: [], - chat: [], - forums: [], - announcements: [], - repos: [], - social: [], - }; - - const marketCapRank = tokenInfoResponse?.data?.market_cap_rank || null; - - const tokenInfo = { - basic: { - name, - symbol, - totalSupply, - imageUri, - circulatingSupply, - }, - extended: { - categories, - - links, - circulatingSupply, - - currentPrice, - priceChangePercentage24h, - currentPriceInBtc, - priceInBtcChangePercentage24h, - - marketCap, - - tradingVolume24h, - tradingVolumeChangePercentage24h, - - developerData, - marketCapRank, - }, - }; - - return tokenInfo; - } catch (error) { - console.error(error); - return { - basic: basicTokenInfo, - }; - } -} - -export async function getTokenInfo( - tokenId: string, - chain: string, - api?: string -): Promise { - const isCustomApi = !!api; - - try { - if (!tokenId || isCustomApi) { - throw new Error('cannot fetch token info for this request'); - } - - const basicTokenInfo = await getBasicTokenInfoFromStacksApi(tokenId, chain, api); - if (!basicTokenInfo) { - console.error('token not found in Stacks API', tokenId, chain, api); - return {}; - } - - const detailedTokenInfo = await getDetailedTokenInfoFromLunarCrush(tokenId, basicTokenInfo); - return detailedTokenInfo; - } catch (error) { - console.error(error); - return {}; - } -} diff --git a/src/app/token/[tokenId]/page.tsx b/src/app/token/[tokenId]/page.tsx index 3e32f51aa..42537f438 100644 --- a/src/app/token/[tokenId]/page.tsx +++ b/src/app/token/[tokenId]/page.tsx @@ -24,8 +24,7 @@ import { Transaction, } from '@stacks/stacks-blockchain-api-types'; -import TokenIdPage from './PageClient'; -import { getTokenInfo } from './getTokenInfo'; +import TokenIdPageRedesign from './PageClient'; import { getTokenDataFromLunarCrush, getTokenDataFromStacksApi, mergeTokenData } from './page-data'; import { TokenIdPageDataProvider } from './redesign/context/TokenIdPageContext'; import { MergedTokenData } from './types'; @@ -61,12 +60,9 @@ export default async function (props: { let holders: FungibleTokenHolderList | undefined; let numFunctions: number | undefined; - const tokenInfo = await getTokenInfo(tokenId, chain || NetworkModes.Mainnet, api); - - const isRedesign = searchParams.redesign === 'true'; const isSSRDisabled = searchParams?.ssr === 'false'; - if (isRedesign && !isSSRDisabled) { + if (!isSSRDisabled) { try { const [ tokenPriceResult, @@ -159,7 +155,7 @@ export default async function (props: { holders={holders} numFunctions={numFunctions} > - + ); } diff --git a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx index 8b7a8ae22..86667c747 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx @@ -1,3 +1,5 @@ +"use client"; + import { TokenImage } from '@/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers'; import { useIsInViewport } from '@/common/hooks/useIsInViewport'; import { diff --git a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx index b5e92bd35..87bbd9fe5 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx @@ -1,3 +1,5 @@ +"use client"; + import { TxTabsTrigger } from '@/app/txid/[txId]/redesign/TxTabs'; import { AvailableFunctions } from '@/app/txid/[txId]/redesign/function-called/AvailableFunctions'; import { Source } from '@/app/txid/[txId]/redesign/source/Source'; From e2841a75d9ac9cf7afe4947869498dcbd5b8b821 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Thu, 30 Oct 2025 15:32:02 -0500 Subject: [PATCH 2/7] feat: wp --- .../token/[tokenId]/Tabs/DeveloperStat.tsx | 26 -- src/app/token/[tokenId]/Tabs/Developers.tsx | 82 ------ .../token/[tokenId]/Tabs/holders/Holders.tsx | 267 ------------------ .../token/[tokenId]/Tabs/holders/skeleton.tsx | 57 ---- src/app/token/[tokenId]/Tabs/index.tsx | 124 -------- .../token/[tokenId]/TokenInfo/MarketCap.tsx | 44 --- src/app/token/[tokenId]/TokenInfo/Price.tsx | 44 --- src/app/token/[tokenId]/TokenInfo/Supply.tsx | 26 -- .../token/[tokenId]/TokenInfo/Transaction.tsx | 34 --- .../token/[tokenId]/TokenInfo/TrendArrow.tsx | 23 -- src/app/token/[tokenId]/TokenInfo/index.tsx | 51 ---- .../token/[tokenId]/__tests__/utils.test.ts | 55 +--- .../[tokenId]/redesign/TokenIdOverview.tsx | 1 + .../token/[tokenId]/redesign/TokenIdTabs.tsx | 6 - src/app/token/[tokenId]/utils.ts | 31 -- .../table/table-examples/HoldersTable.tsx | 2 +- .../data => common/queries}/useHolders.ts | 0 17 files changed, 3 insertions(+), 870 deletions(-) delete mode 100644 src/app/token/[tokenId]/Tabs/DeveloperStat.tsx delete mode 100644 src/app/token/[tokenId]/Tabs/Developers.tsx delete mode 100644 src/app/token/[tokenId]/Tabs/holders/Holders.tsx delete mode 100644 src/app/token/[tokenId]/Tabs/holders/skeleton.tsx delete mode 100644 src/app/token/[tokenId]/Tabs/index.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/MarketCap.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/Price.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/Supply.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/Transaction.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/TrendArrow.tsx delete mode 100644 src/app/token/[tokenId]/TokenInfo/index.tsx rename src/{app/token/[tokenId]/Tabs/data => common/queries}/useHolders.ts (100%) diff --git a/src/app/token/[tokenId]/Tabs/DeveloperStat.tsx b/src/app/token/[tokenId]/Tabs/DeveloperStat.tsx deleted file mode 100644 index caf9d92b5..000000000 --- a/src/app/token/[tokenId]/Tabs/DeveloperStat.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Flex, Icon, Stack, StackProps } from '@chakra-ui/react'; -import { FC, ReactNode } from 'react'; - -import { Text } from '../../../../ui/Text'; - -export const DeveloperStat: FC< - { - value: number | string; - label: string; - icon: ReactNode; - } & StackProps -> = ({ value, label, icon, border, ...stackProps }) => { - return ( - - - {value} - - - {icon && {icon}} - - {label} - - - - ); -}; diff --git a/src/app/token/[tokenId]/Tabs/Developers.tsx b/src/app/token/[tokenId]/Tabs/Developers.tsx deleted file mode 100644 index 2ba2fb44b..000000000 --- a/src/app/token/[tokenId]/Tabs/Developers.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Flex } from '@chakra-ui/react'; -import { - Eye, - GitCommit, - GitFork, - GitMerge, - MinusSquare, - PlusSquare, - Star, - Users, -} from '@phosphor-icons/react'; -import { FC } from 'react'; - -import { DeveloperData } from '../types'; -import { DeveloperStat } from './DeveloperStat'; - -export const Developers: FC<{ developerData: DeveloperData }> = ({ developerData }) => { - const developerStat = [ - { - value: developerData?.forks || 0, - label: 'Forks', - icon: , - }, - { - value: developerData?.stars || 0, - label: 'Stars', - icon: , - }, - { - value: developerData?.subscribers || 0, - label: 'Subscribers', - icon: , - }, - { - value: `${developerData?.closed_issues || 0} / ${developerData?.total_issues || 0}`, - label: 'Closed issues / Total issues', - icon: null, - }, - { - value: developerData?.pull_requests_merged || 0, - label: 'PRs merged', - icon: , - }, - { - value: developerData?.pull_request_contributors || 0, - label: 'PR contributors', - icon: , - }, - { - value: developerData?.commit_count_4_weeks || 0, - label: 'Commits (4 weeks)', - icon: , - }, - { - value: developerData?.code_additions_deletions_4_weeks?.additions || 0, - label: 'Additions (4 weeks)', - icon: , - }, - { - value: developerData?.code_additions_deletions_4_weeks?.deletions || 0, - label: 'Deletions (4 weeks)', - icon: , - }, - ]; - return ( - - {developerStat.map(({ icon, label, value }, index) => ( - - ))} - - ); -}; diff --git a/src/app/token/[tokenId]/Tabs/holders/Holders.tsx b/src/app/token/[tokenId]/Tabs/holders/Holders.tsx deleted file mode 100644 index 5cb3cd3ee..000000000 --- a/src/app/token/[tokenId]/Tabs/holders/Holders.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { Flex, Table } from '@chakra-ui/react'; -import styled from '@emotion/styled'; -import { ReactNode, Suspense, useMemo } from 'react'; - -import { AddressLink } from '../../../../../common/components/ExplorerLinks'; -import { ListFooter } from '../../../../../common/components/ListFooter'; -import { Section } from '../../../../../common/components/Section'; -import { useContractById } from '../../../../../common/queries/useContractById'; -import { useFtMetadata } from '../../../../../common/queries/useFtMetadata'; -import { ftDecimals, truncateMiddleDeprecated } from '../../../../../common/utils/utils'; -import { Text } from '../../../../../ui/Text'; -import { ScrollableBox } from '../../../../_components/BlockList/ScrollableDiv'; -import { mobileBorderCss } from '../../../../_components/BlockList/consts'; -import { ExplorerErrorBoundary } from '../../../../_components/ErrorBoundary'; -import { TokenInfoProps } from '../../types'; -import { useSuspenseFtHolders } from '../data/useHolders'; -import { HoldersTableSkeleton } from './skeleton'; - -const StyledTable = styled(Table.Root)` - th { - border-bottom: none; - } - - tr:last-child td { - border-bottom: none; - } -`; - -export const HoldersTableHeader = ({ - headerTitle, - isFirst, -}: { - headerTitle: string; - isFirst: boolean; -}) => ( - - - - {headerTitle} - - - -); - -type HeaderValue = '#' | 'Address' | 'Quantity' | 'Percentage' | 'Value'; -export const holdersTableHeaders: HeaderValue[] = [ - '#', - 'Address', - 'Quantity', - 'Percentage', - 'Value', -]; - -export const HoldersTableHeaders = ({ hasPrice }: { hasPrice: boolean }) => ( - - {holdersTableHeaders.map((header, i) => { - return !hasPrice && header === 'Value' ? null : ( - - ); - })} - -); - -interface HolderRowInfo { - address: string; - balance: string; - percentage: string; - value: string; -} - -function generateHolderRowInfo( - address: string, - balance: number, - totalSupply: number, - tokenPrice: number | null | undefined, - decimals: number | undefined -): HolderRowInfo { - return { - address, - balance: ftDecimals(balance, decimals || 0).toLocaleString(), - percentage: `${((balance / totalSupply) * 100).toFixed(2).toLocaleString()}%`, - value: - typeof tokenPrice === 'number' ? (balance * tokenPrice).toFixed(2).toLocaleString() : 'N/A', - }; -} - -const HolderTableRow = ({ - index, - isFirst, - isLast, - address, - balance, - percentage, - value, -}: { - index: number; - isFirst: boolean; - isLast: boolean; -} & HolderRowInfo) => { - const hasPrice = !!!value; - return ( - - - - {index + 1} - - - - - - {truncateMiddleDeprecated(address)} - - - - - {balance} - - - - - - {percentage} - - - {hasPrice ? ( - - - {value} - - - ) : null} - - ); -}; - -export function HoldersTableLayout({ - numHolders, - holdersTableHeaders, - holdersTableRows, -}: { - numHolders: ReactNode; - holdersTableHeaders: ReactNode; - holdersTableRows: ReactNode; -}) { - return ( -
- - - {holdersTableHeaders} - {holdersTableRows} - - -
- ); -} - -const HoldersTableBase = ({ - tokenId, - tokenInfo, -}: { - tokenId: string; - tokenInfo: TokenInfoProps; -}) => { - const tokenPrice = tokenInfo.extended?.currentPrice; - // TODO: use asset id from token metadata. api is going to add it soon - const { data: contract } = useContractById(tokenId); - const ftName = contract?.abi?.fungible_tokens[0].name; - const response = useSuspenseFtHolders(`${tokenId}::${ftName}`); - const { isFetchingNextPage, fetchNextPage, hasNextPage } = response; - console.log({ response }); - const { - total: totalNumHolders, - total_supply: totalSupply, - results: holderBalances, - } = response.data.pages[0]; - const filteredHolderBalances = holderBalances.filter(holder => holder.balance !== '0'); - const { data: tokenMetadata } = useFtMetadata(contract?.contract_id); - const decimals = useMemo(() => tokenMetadata?.decimals, [tokenMetadata]); - - if (!holderBalances || !totalNumHolders || !totalSupply) { - throw new Error('Holders data is not available'); - } - - return ( - <> - {totalNumHolders.toLocaleString()} Holders} - holdersTableHeaders={} - holdersTableRows={filteredHolderBalances.map((holder, i) => { - const { address, balance } = holder; - return ( - - ); - })} - /> - - - ); -}; - -const HoldersTable = ({ tokenId, tokenInfo }: { tokenId: string; tokenInfo: TokenInfoProps }) => { - return ( - - }> - - - - ); -}; - -export default HoldersTable; diff --git a/src/app/token/[tokenId]/Tabs/holders/skeleton.tsx b/src/app/token/[tokenId]/Tabs/holders/skeleton.tsx deleted file mode 100644 index 24ea7dba3..000000000 --- a/src/app/token/[tokenId]/Tabs/holders/skeleton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import { Flex, Table } from '@chakra-ui/react'; - -import { SkeletonText } from '../../../../../components/ui/skeleton'; -import { Skeleton } from '../../../../../ui/Skeleton'; -import { HoldersTableLayout, holdersTableHeaders } from './Holders'; - -const TableRowSkeleton = ({ numCols }: { numCols: number }) => { - const cols = Array.from({ length: numCols }, (_, i) => i + 1); - - return ( - - {cols.map((_, index) => ( - - - - ))} - - ); -}; - -const TableHeaderSkeleton = () => ( - - - - - -); - -export const HoldersTableSkeleton = () => { - const numRows = Array.from({ length: 10 }, (_, i) => i + 1); - const numCols = Array.from({ length: holdersTableHeaders.length }, (_, i) => i + 1); - - return ( - } - holdersTableHeaders={ - - {numCols.map((_, i) => ( - - ))} - - } - holdersTableRows={numRows.map((_, i) => ( - - ))} - /> - ); -}; diff --git a/src/app/token/[tokenId]/Tabs/index.tsx b/src/app/token/[tokenId]/Tabs/index.tsx deleted file mode 100644 index 943d97179..000000000 --- a/src/app/token/[tokenId]/Tabs/index.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { getIsSBTC } from '@/app/tokens/utils'; - -import { ContractAvailableFunctions } from '../../../../common/components/ContractAvailableFunctions'; -import { TabsContainer } from '../../../../common/components/TabsContainer'; -import { - useContractById, - useSuspenseContractById, -} from '../../../../common/queries/useContractById'; -import { AddressConfirmedTxsList } from '../../../../features/txs-list/AddressConfirmedTxsList'; -import { AddressMempoolTxsList } from '../../../../features/txs-list/AddressMempoolTxsList'; -import { CodeEditor } from '../../../../ui/CodeEditor'; -import { TabsRootProps } from '../../../../ui/Tabs'; -import { ExplorerErrorBoundary } from '../../../_components/ErrorBoundary'; -import { sbtcDepositAddress, sbtcWidthdrawlContractAddress } from '../consts'; -import { DeveloperData, TokenInfoProps } from '../types'; -import { Developers } from './Developers'; -import HoldersTable from './holders/Holders'; - -interface TokenTabsProps extends Partial { - tokenId: string; - tokenInfo: TokenInfoProps; - developerData?: DeveloperData; -} - -export function TokenTabsBase({ tokenId, tokenInfo, developerData }: TokenTabsProps) { - const { data: contract } = useSuspenseContractById(tokenId); - const source = contract?.source_code; - const isSBTC = getIsSBTC(tokenId); - const { data: sbtcWithdrawalContract } = useContractById( - isSBTC ? sbtcWidthdrawlContractAddress : undefined - ); - - return ( - , - }, - { - title: 'Pending', - id: 'pending', - content: , - }, - ...(isSBTC - ? [ - { - title: 'Confirmed Deposits', - id: 'confirmed-deposits', - content: , - }, - ] - : []), - ...(isSBTC - ? [ - { - title: 'Pending Deposits', - id: 'pending-deposits', - content: , - }, - ] - : []), - ...(isSBTC && sbtcWithdrawalContract - ? [ - { - title: 'Withdraw Deposits', - id: 'withdraw-deposits', - content: ( - - ), - }, - ] - : []), - ...(!!source - ? [ - { - title: 'Source code', - id: 'source', - content: , - }, - ] - : []), - ...(!!contract - ? [ - { - title: 'Available functions', - id: 'available-functions', - content: , - }, - ] - : []), - ...(!!developerData - ? [ - { - title: 'Developers', - id: 'developers', - content: , - }, - ] - : []), - - { - title: 'Holders', - id: 'holders', - content: , - }, - ]} - actions={null} - gridColumnEnd={'3'} - /> - ); -} - -export function TokenTabs(props: TokenTabsProps) { - return ( - - - - ); -} diff --git a/src/app/token/[tokenId]/TokenInfo/MarketCap.tsx b/src/app/token/[tokenId]/TokenInfo/MarketCap.tsx deleted file mode 100644 index 40cab64bb..000000000 --- a/src/app/token/[tokenId]/TokenInfo/MarketCap.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Flex, StackProps } from '@chakra-ui/react'; -import { FC } from 'react'; - -import { abbreviateNumber } from '../../../../common/utils/utils'; -import { StatSection } from '../../../_components/Stats/StatSection'; -import { TrendArrow } from './TrendArrow'; - -export const MarketCap: FC< - StackProps & { - marketCap: number | null | undefined; - tradingVolume24h: number | null | undefined; - tradingVolumeChangePercentage24h: number | null | undefined; - marketCapOverride?: number | null | undefined; - } -> = ({ - marketCap, - tradingVolumeChangePercentage24h, - tradingVolume24h, - marketCapOverride, - ...stackProps -}) => { - return ( - - Trading Volume: ${tradingVolume24h ? abbreviateNumber(tradingVolume24h, 2) : 'N/A'} - {tradingVolumeChangePercentage24h ? ( - - ) : null} - - } - {...stackProps} - /> - ); -}; diff --git a/src/app/token/[tokenId]/TokenInfo/Price.tsx b/src/app/token/[tokenId]/TokenInfo/Price.tsx deleted file mode 100644 index b8f6e26bb..000000000 --- a/src/app/token/[tokenId]/TokenInfo/Price.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { getIsSBTC } from '@/app/tokens/utils'; -import { Flex, StackProps } from '@chakra-ui/react'; -import { FC } from 'react'; - -import { StatSection } from '../../../_components/Stats/StatSection'; -import { TrendArrow } from './TrendArrow'; - -export const Price: FC< - StackProps & { - currentPrice: number | null | undefined; - priceChangePercentage24h: number | null | undefined; - currentPriceInBtc: number | null | undefined; - tokenId: string; - } -> = ({ currentPrice, priceChangePercentage24h, currentPriceInBtc, tokenId, ...stackProps }) => { - const isSBTC = getIsSBTC(tokenId); - return ( - : null - } - caption={ - - {isSBTC - ? null - : currentPriceInBtc - ? `${parseFloat(currentPriceInBtc.toFixed(8)).toLocaleString(undefined, { - maximumFractionDigits: 8, - })} BTC` - : 'N/A'} - - } - {...stackProps} - /> - ); -}; diff --git a/src/app/token/[tokenId]/TokenInfo/Supply.tsx b/src/app/token/[tokenId]/TokenInfo/Supply.tsx deleted file mode 100644 index 33a8491c8..000000000 --- a/src/app/token/[tokenId]/TokenInfo/Supply.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Flex, StackProps } from '@chakra-ui/react'; -import { FC } from 'react'; - -import { abbreviateNumber } from '../../../../common/utils/utils'; -import { StatSection } from '../../../_components/Stats/StatSection'; - -export const Supply: FC< - StackProps & { - circulatingSupply: number | null | undefined; - totalSupply: number | null | undefined; - } -> = ({ circulatingSupply, totalSupply, ...stackProps }) => { - return ( - N/A} - bodySecondaryText={null} - caption={ - - Total Supply: {totalSupply ? abbreviateNumber(totalSupply) : 'N/A'} - - } - {...stackProps} - /> - ); -}; diff --git a/src/app/token/[tokenId]/TokenInfo/Transaction.tsx b/src/app/token/[tokenId]/TokenInfo/Transaction.tsx deleted file mode 100644 index e4b290104..000000000 --- a/src/app/token/[tokenId]/TokenInfo/Transaction.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { truncateMiddleDeprecated } from '@/common/utils/utils'; -import { Box, Flex, StackProps } from '@chakra-ui/react'; -import { FC } from 'react'; - -import { TxLink } from '../../../../common/components/ExplorerLinks'; -import { StatSection } from '../../../_components/Stats/StatSection'; - -export const Transaction: FC< - StackProps & { txId: string; marketCapRank: number | null | undefined } -> = ({ marketCapRank, txId, ...stackProps }) => { - return ( - - {truncateMiddleDeprecated(txId, 4)} - - } - bodySecondaryText={null} - caption={ - - Market Cap Rank: {marketCapRank || 'N/A'} - - } - {...stackProps} - /> - ); -}; diff --git a/src/app/token/[tokenId]/TokenInfo/TrendArrow.tsx b/src/app/token/[tokenId]/TokenInfo/TrendArrow.tsx deleted file mode 100644 index 727d0202b..000000000 --- a/src/app/token/[tokenId]/TokenInfo/TrendArrow.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Flex, Icon } from '@chakra-ui/react'; -import { CaretDown, CaretUp } from '@phosphor-icons/react'; -import { FC } from 'react'; - -import { Text } from '../../../../ui/Text'; - -export const TrendArrow: FC<{ change: number; size: string }> = ({ change, size }) => { - return ( - - {change >= 0 ? ( - - - - ) : ( - - - - )} -   - = 0 ? 'success' : 'error'}>{Math.round(change * 10) / 10}% - - ); -}; diff --git a/src/app/token/[tokenId]/TokenInfo/index.tsx b/src/app/token/[tokenId]/TokenInfo/index.tsx deleted file mode 100644 index 2fcc8775c..000000000 --- a/src/app/token/[tokenId]/TokenInfo/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { FC } from 'react'; -import { ErrorBoundary } from 'react-error-boundary'; - -import { getIsSBTC } from '../../../../app/tokens/utils'; -import { StatsWrapper } from '../../../_components/Stats/StatsWrapper'; -import { TokenInfoProps } from '../types'; -import { MarketCap } from './MarketCap'; -import { Price } from './Price'; -import { Supply } from './Supply'; -import { Transaction } from './Transaction'; - -export const TokenInfo: FC<{ tokenInfo: TokenInfoProps; tokenId: string }> = ({ - tokenInfo, - tokenId, -}) => { - const circulatingSupply = - tokenInfo?.extended?.circulatingSupply || tokenInfo?.basic?.circulatingSupply; - const currentPrice = tokenInfo?.extended?.currentPrice; - const isSBTC = getIsSBTC(tokenId); - const sBTCMarketCapOverride = // LunarCrush is returning an incorrect circulating supply for SBTC, resulting in an incorrect market cap. Manually overriding it here. - circulatingSupply && currentPrice ? circulatingSupply * currentPrice : undefined; - return ( - null}> - - - - - - - - ); -}; diff --git a/src/app/token/[tokenId]/__tests__/utils.test.ts b/src/app/token/[tokenId]/__tests__/utils.test.ts index ff8ee682c..37e851bea 100644 --- a/src/app/token/[tokenId]/__tests__/utils.test.ts +++ b/src/app/token/[tokenId]/__tests__/utils.test.ts @@ -1,57 +1,4 @@ -import { ArrowSquareOut, DiscordLogo, TelegramLogo, TwitterLogo } from '@phosphor-icons/react'; - -import StxIcon from '../../../../ui/icons/StxIcon'; -import { getLinkIcon, getUrlName, isExplorerLink, isRiskyNFTContract } from '../utils'; - -describe('isExplorerLink', () => { - it('should return true for stacks.co explorer url', () => { - expect(isExplorerLink('https://explorer.stacks.co')).toBe(true); - }); - - it('should return true for hiro.so explorer url', () => { - expect(isExplorerLink('https://explorer.hiro.so')).toBe(true); - }); - - it('should return false for non explorer url', () => { - expect(isExplorerLink('https://github.com')).toBe(false); - }); -}); - -describe('getUrlName', () => { - it('should return hostname without protocol and www', () => { - expect(getUrlName('https://www.github.com')).toBe('github.com'); - }); - - it('should return hostname without protocol for urls without www', () => { - expect(getUrlName('https://twitter.com')).toBe('twitter.com'); - }); - - it('should return undefined for invalid urls', () => { - expect(getUrlName('notAUrl')).toBe(undefined); - }); -}); - -describe('getLinkIcon', () => { - it('should return BsDiscord for discord url', () => { - expect(getLinkIcon('https://discord.com/')).toBe(DiscordLogo); - }); - - it('should return StxIcon for explorer url', () => { - expect(getLinkIcon('https://explorer.stacks.co')).toBe(StxIcon); - }); - - it('should return BsTwitter for twitter url', () => { - expect(getLinkIcon('https://twitter.com/')).toBe(TwitterLogo); - }); - - it('should return BiLogoTelegram for telegram url', () => { - expect(getLinkIcon('https://t.me/')).toBe(TelegramLogo); - }); - - it('should return BiLinkExternal for any other url', () => { - expect(getLinkIcon('https://github.com')).toBe(ArrowSquareOut); - }); -}); +import { isRiskyNFTContract } from '../utils'; describe('isRiskyNFTContract', () => { it('should return true for contract names ending with .StacksDao', () => { diff --git a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx index bd6da6f56..601287746 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx @@ -72,6 +72,7 @@ const NO_DATA = ( export function MarketDataCard() { const { tokenData, holders } = useTokenIdPageData(); + console.log('MarketDataCard', { tokenData }); const circulatingSupply = holders?.total_supply && tokenData?.decimals !== undefined diff --git a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx index 87bbd9fe5..ea3937a25 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx @@ -95,12 +95,6 @@ export const TokenIdTabs = () => { isActive={selectedTab === TokenIdPageTab.Source} onClick={() => setSelectedTab(TokenIdPageTab.Source)} /> - setSelectedTab(TokenIdPageTab.Source)} - /> { - return url.includes('explorer.stacks.co') || url.includes('explorer.hiro.so'); -}; - -export const getUrlName = (url: string) => { - try { - const urlObj = new URL(url); - return urlObj.hostname.replace('http://', '').replace('https://', '').replace('www.', ''); - } catch (_) { - return undefined; - } -}; - -export const getLinkIcon = (url: string) => { - if (url.includes('discord.com/')) { - return DiscordLogo; - } - if (isExplorerLink(url)) { - return StxIcon; - } - if (url.includes('twitter.com/')) { - return TwitterLogo; - } - if (url.includes('/t.me/')) { - return TelegramLogo; - } - return ArrowSquareOut; -}; - /** * Checks if a contract name matches any of the risky NFT patterns * @param contractName The contract name to check diff --git a/src/common/components/table/table-examples/HoldersTable.tsx b/src/common/components/table/table-examples/HoldersTable.tsx index 9035f5609..96d856c9e 100644 --- a/src/common/components/table/table-examples/HoldersTable.tsx +++ b/src/common/components/table/table-examples/HoldersTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { getHoldersQueryKey, useHolders } from '@/app/token/[tokenId]/Tabs/data/useHolders'; +import { getHoldersQueryKey, useHolders } from '@/common/queries/useHolders'; import { GenericResponseType } from '@/common/hooks/useInfiniteQueryResult'; import { THIRTY_SECONDS } from '@/common/queries/query-stale-time'; import { diff --git a/src/app/token/[tokenId]/Tabs/data/useHolders.ts b/src/common/queries/useHolders.ts similarity index 100% rename from src/app/token/[tokenId]/Tabs/data/useHolders.ts rename to src/common/queries/useHolders.ts From 8509c1219cbd7633f6709d02eda26b5378163f39 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Wed, 5 Nov 2025 17:04:26 -0600 Subject: [PATCH 3/7] feat: wp --- src/app/token/[tokenId]/PageClient.tsx | 3 ++ src/app/token/[tokenId]/consts.ts | 2 +- src/app/token/[tokenId]/page-data.ts | 6 +-- src/app/token/[tokenId]/page.tsx | 11 ++++ .../token/[tokenId]/redesign/TokenAlert.tsx | 27 ++++++++++ src/app/tokens/TokenRow/index.tsx | 16 +++--- src/app/tokens/utils.ts | 17 +++--- src/app/txid/[txId]/redesign/Alert.tsx | 53 ++++++++++++++++++- 8 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 src/app/token/[tokenId]/redesign/TokenAlert.tsx diff --git a/src/app/token/[tokenId]/PageClient.tsx b/src/app/token/[tokenId]/PageClient.tsx index 1e81135f0..a2da7ab6b 100644 --- a/src/app/token/[tokenId]/PageClient.tsx +++ b/src/app/token/[tokenId]/PageClient.tsx @@ -1,5 +1,6 @@ import { Stack } from '@chakra-ui/react'; +import { TokenAlert } from './redesign/TokenAlert'; import { TokenIdHeader } from './redesign/TokenIdHeader'; import { TokenIdTabs } from './redesign/TokenIdTabs'; @@ -7,7 +8,9 @@ export default function TokenIdPageRedesign() { return ( + ); } + \ No newline at end of file diff --git a/src/app/token/[tokenId]/consts.ts b/src/app/token/[tokenId]/consts.ts index d9a4be4c7..337ed147f 100644 --- a/src/app/token/[tokenId]/consts.ts +++ b/src/app/token/[tokenId]/consts.ts @@ -21,5 +21,5 @@ export const RISKY_NFTS = [ export const RISKY_NFT_RULES = [ /\.StacksDao$/, // Exact match for contract names ending with .StacksDao (case sensitive) ]; -export const legitsBTCDerivatives = [zsbtcContractAddress]; +export const LEGIT_SBTC_DERIVATIVES = [zsbtcContractAddress]; export const VERIFIED_TOKENS = [DROID_CONTRACT_ADDRESS, zsbtcContractAddress, sbtcContractAddress]; diff --git a/src/app/token/[tokenId]/page-data.ts b/src/app/token/[tokenId]/page-data.ts index 462b07ca6..1043cbb3e 100644 --- a/src/app/token/[tokenId]/page-data.ts +++ b/src/app/token/[tokenId]/page-data.ts @@ -1,9 +1,9 @@ import { fetchTokenDataFromLunarCrush, fetchTokenMetadata } from '@/api/data-fetchers'; +import { isSBTC } from '@/app/tokens/utils'; import { logError } from '@/common/utils/error-utils'; import { FungibleTokenHolderList } from '@stacks/stacks-blockchain-api-types'; -import { getIsSBTC } from '../../tokens/utils'; import { DeveloperDataRedesign, MergedTokenData, @@ -132,9 +132,9 @@ export function mergeTokenData( : undefined; // Special handling for circulating supply for SBTC. If it's SBTC, use the holders total supply (aka circulating supply), otherwise use the circulating supply from LunarCrush first, then fallback to the circulating supply from Stacks API - const isSBTC = getIsSBTC(tokenId); + const isSbtc = isSBTC(tokenId); const circulatingSupplyFromStacksApi = parseFloat(holders?.total_supply || '0'); - const circulatingSupply = isSBTC + const circulatingSupply = isSbtc ? safeGet(circulatingSupplyFromStacksApi, tokenDataFromLunarCrush?.circulatingSupply) : safeGet(tokenDataFromLunarCrush?.circulatingSupply, circulatingSupplyFromStacksApi); diff --git a/src/app/token/[tokenId]/page.tsx b/src/app/token/[tokenId]/page.tsx index 42537f438..2e4a05091 100644 --- a/src/app/token/[tokenId]/page.tsx +++ b/src/app/token/[tokenId]/page.tsx @@ -142,6 +142,17 @@ export default async function (props: { } } + console.log({ + tokenData, + tokenPrice, + initialAddressRecentTransactionsData, + txBlockTime, + txId, + assetId, + holders, + numFunctions, + }) + return ( ; + } + + if (RISKY_TOKENS.includes(contractId)) { + return ; + } + + return null; +} diff --git a/src/app/tokens/TokenRow/index.tsx b/src/app/tokens/TokenRow/index.tsx index 51862398d..1c8e79abb 100644 --- a/src/app/tokens/TokenRow/index.tsx +++ b/src/app/tokens/TokenRow/index.tsx @@ -8,18 +8,20 @@ import { TokenLink, TxLink } from '../../../common/components/ExplorerLinks'; import { abbreviateNumber, getFtDecimalAdjustedBalance } from '../../../common/utils/utils'; import { Text } from '../../../ui/Text'; import { TokenAvatar } from '../../address/[principal]/TokenBalanceCard/TokenAvatar'; -import { getHasSBTCInName, getIsSBTC } from '../utils'; +import { isSBTC, referencesSBTC } from '../utils'; export const TokenRow: FC<{ ftToken: FtBasicMetadataResponse; }> = ({ ftToken }) => { const name = ftToken.name || 'FT Token'; + const symbol = ftToken.symbol || ''; + const contractId = ftToken.contract_principal; - const hasSBTCInName = getHasSBTCInName(name, ftToken.symbol ?? ''); - const isSBTC = getIsSBTC(ftToken.contract_principal); + const includesSbtc = referencesSBTC(name, symbol); + const isSbtc = isSBTC(contractId); const tokenBadge = useMemo(() => { - if (isSBTC || VERIFIED_TOKENS.includes(ftToken.contract_principal)) { + if (isSbtc || VERIFIED_TOKENS.includes(contractId)) { return ( ); } - if ((hasSBTCInName && !isSBTC) || RISKY_TOKENS.includes(ftToken.contract_principal)) { + if ((includesSbtc && !isSbtc) || RISKY_TOKENS.includes(contractId)) { return ( @@ -58,7 +60,7 @@ export const TokenRow: FC<{ { - if (!name || !symbol) { +export const referencesSBTC = ( + tokenName: FtBasicMetadataResponse['name'], + tokenSymbol: FtBasicMetadataResponse['symbol'] +) => { + if (!tokenName || !tokenSymbol) { return false; } - return name.toLowerCase().includes('sbtc') || symbol.toLowerCase().includes('sbtc'); + return tokenName.toLowerCase().includes('sbtc') || tokenSymbol.toLowerCase().includes('sbtc'); }; -export const getIsSBTC = (contractPrincipal: string) => { - if (!contractPrincipal) { +export const isSBTC = (contractId: string) => { + if (!contractId) { return false; } - return contractPrincipal === sbtcContractAddress; + return contractId === sbtcContractAddress; }; diff --git a/src/app/txid/[txId]/redesign/Alert.tsx b/src/app/txid/[txId]/redesign/Alert.tsx index b80810fae..90e474473 100644 --- a/src/app/txid/[txId]/redesign/Alert.tsx +++ b/src/app/txid/[txId]/redesign/Alert.tsx @@ -1,3 +1,5 @@ +"use client"; + import { TransactionStatus as TransactionStatusEnum } from '@/common/constants/constants'; import { getTransactionStatus } from '@/common/utils/transactions'; import { Alert } from '@/components/ui/alert'; @@ -174,7 +176,7 @@ export function getTxAlert(tx: Transaction | MempoolTransaction) { return alertContent; } -export function SuspiciousTokenAlert() { +export function UnknownOrNewlyIssuedTokenAlert() { return ( + ); +} + +export function RiskyTokenAlert() { + return ( + + ); +} + +export function NotSBTCTokenAlert() { + return ( + + This is not{' '} + + the official sBTC token + {' '} + and may be a scam. Engaging with unverified tokens could result in loss of funds. + + } + alertBg="colors.feedback.yellow-200" + alertIconColor="colors.feedback.yellow-700" + /> + ); +} + +export function Sip10Alert() { + return ( + ); From f070142b232406862051f675c19a3a86ca78c134 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Wed, 5 Nov 2025 19:50:13 -0600 Subject: [PATCH 4/7] feat: wp --- src/app/token/[tokenId]/PageClient.tsx | 3 +- src/app/token/[tokenId]/page.tsx | 11 -- .../token/[tokenId]/redesign/TokenAlert.tsx | 12 +- .../[tokenId]/redesign/TokenIdHeader.tsx | 112 +++++++++++++----- .../[tokenId]/redesign/TokenIdOverview.tsx | 96 +++++++++------ .../token/[tokenId]/redesign/TokenIdTabs.tsx | 2 +- src/app/tokens/utils.ts | 19 ++- src/app/txid/[txId]/redesign/Alert.tsx | 44 ++++--- src/common/components/id-pages/Overview.tsx | 36 +++++- .../FungibleTokensTableCellRenderers.tsx | 22 +++- .../table/table-examples/HoldersTable.tsx | 2 +- src/common/utils/fungible-token-utils.tsx | 4 +- src/components/ui/alert.tsx | 10 +- 13 files changed, 260 insertions(+), 113 deletions(-) diff --git a/src/app/token/[tokenId]/PageClient.tsx b/src/app/token/[tokenId]/PageClient.tsx index a2da7ab6b..c3ad33a7c 100644 --- a/src/app/token/[tokenId]/PageClient.tsx +++ b/src/app/token/[tokenId]/PageClient.tsx @@ -1,3 +1,4 @@ +import { Sip10Alert } from '@/app/txid/[txId]/redesign/Alert'; import { Stack } from '@chakra-ui/react'; import { TokenAlert } from './redesign/TokenAlert'; @@ -10,7 +11,7 @@ export default function TokenIdPageRedesign() { + ); } - \ No newline at end of file diff --git a/src/app/token/[tokenId]/page.tsx b/src/app/token/[tokenId]/page.tsx index 2e4a05091..42537f438 100644 --- a/src/app/token/[tokenId]/page.tsx +++ b/src/app/token/[tokenId]/page.tsx @@ -142,17 +142,6 @@ export default async function (props: { } } - console.log({ - tokenData, - tokenPrice, - initialAddressRecentTransactionsData, - txBlockTime, - txId, - assetId, - holders, - numFunctions, - }) - return ( ; } - if (RISKY_TOKENS.includes(contractId)) { + if (showRiskyTokenAlert(contractId)) { return ; } diff --git a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx index 86667c747..d5506eb88 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx @@ -1,18 +1,14 @@ -"use client"; +'use client'; +import { showRiskyTokenAlert, showSBTCTokenAlert } from '@/app/tokens/utils'; import { TokenImage } from '@/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers'; import { useIsInViewport } from '@/common/hooks/useIsInViewport'; -import { - truncateStxAddress, - truncateStxContractId, - validateStacksContractId, -} from '@/common/utils/utils'; +import { getAssetNameParts } from '@/common/utils/utils'; import { DefaultBadge, DefaultBadgeIcon, DefaultBadgeLabel } from '@/ui/Badge'; import { Text, TextProps } from '@/ui/Text'; import { Tooltip } from '@/ui/Tooltip'; -import StacksIconBlock from '@/ui/icons/StacksIconBlock'; -import { Box, Flex, Stack, useClipboard } from '@chakra-ui/react'; -import { Coin } from '@phosphor-icons/react'; +import { Box, Flex, Icon, Stack, useClipboard } from '@chakra-ui/react'; +import { Coin, Warning } from '@phosphor-icons/react'; import { motion } from 'motion/react'; import { forwardRef, useRef } from 'react'; @@ -59,7 +55,7 @@ const Badge = ({ ); }; -const TokenNameBadgeUnminimized = ({ name }: { name: string }) => { +const TokenNameUnminimized = ({ name }: { name: string }) => { return name ? ( {name} @@ -67,6 +63,14 @@ const TokenNameBadgeUnminimized = ({ name }: { name: string }) => { ) : null; }; +const TokenNameMinimized = ({ name }: { name: string }) => { + return name ? ( + + {name} + + ) : null; +}; + const TokenSymbolBadgeUnminimized = ({ symbol }: { symbol: string }) => { return symbol ? ( { ) : null; }; -const TokenIdBadgeMinimized = ({ tokenId }: { tokenId: string }) => { - const isContract = validateStacksContractId(tokenId); - return tokenId ? ( +const TokenSymbolBadgeMinimized = ({ symbol }: { symbol: string }) => { + return symbol ? ( ) : null; }; -const TokenIdLabelBadgeUnminimized = () => { +const TokenBadgeUnminimized = () => { return ( } color="iconInvert" size={3} bg="iconPrimary" />} @@ -99,16 +102,45 @@ const TokenIdLabelBadgeUnminimized = () => { ); }; -const TokenIdLabelBadgeMinimized = () => { +const TokenBadgeMinimized = () => { return ( - } size={4.5} />} /> + } color="iconInvert" size={3} bg="iconPrimary" />} + p={1} + /> ); }; +const WarningIcon = ({ + tokenName, + tokenSymbol, + contractId, +}: { + tokenName: string; + tokenSymbol: string; + contractId: string; +}) => { + const icon = ( + + + + ); + + if (showSBTCTokenAlert(tokenName, tokenSymbol, contractId)) { + return icon; + } + + if (showRiskyTokenAlert(contractId)) { + return icon; + } + + return null; +}; + export const TokenIdHeaderUnminimized = forwardRef< HTMLDivElement, - { name: string; symbol: string; imageUrl: string } ->(({ name, symbol, imageUrl }, ref) => { + { name: string; symbol: string; imageUrl: string; contractId: string } +>(({ name, symbol, imageUrl, contractId }, ref) => { return ( - + - - + + + ); }); -export const TokenIdHeaderMinimized = ({ tokenId }: { tokenId: string }) => { +export const TokenIdHeaderMinimized = ({ + name, + symbol, + imageUrl, + contractId, +}: { + name: string; + symbol: string; + imageUrl: string; + contractId: string; +}) => { return ( { alignItems="center" > - - + + + + + + + @@ -157,6 +205,8 @@ export const TokenIdHeaderMinimized = ({ tokenId }: { tokenId: string }) => { export const TokenIdHeader = () => { const { tokenId, tokenData } = useTokenIdPageData(); const { name, symbol, imageUri } = tokenData || {}; + const { address, contract } = getAssetNameParts(tokenId || ''); + const contractId = `${address}.${contract}`; const headerRef = useRef(null); const isHeaderInView = useIsInViewport(headerRef); @@ -167,6 +217,7 @@ export const TokenIdHeader = () => { symbol={symbol || ''} imageUrl={imageUri || ''} ref={headerRef} + contractId={contractId} /> { }} > - + diff --git a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx index 601287746..23154cd1f 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx @@ -26,7 +26,14 @@ export const TokenIdOverviewTable = () => { } + valueRenderer={value => ( + + )} showCopyButton /> { label="Contract deploy transaction" value={txId} valueRenderer={value => ( - + {value} )} @@ -55,7 +62,14 @@ export const TokenIdOverviewTable = () => { } + valueRenderer={value => ( + + )} showCopyButton /> )} @@ -65,14 +79,13 @@ export const TokenIdOverviewTable = () => { }; const NO_DATA = ( - + No data available ); export function MarketDataCard() { const { tokenData, holders } = useTokenIdPageData(); - console.log('MarketDataCard', { tokenData }); const circulatingSupply = holders?.total_supply && tokenData?.decimals !== undefined @@ -81,7 +94,7 @@ export function MarketDataCard() { 0, tokenData?.decimals ) - : NO_DATA; + : undefined; const totalSupply = tokenData?.totalSupply && tokenData?.decimals !== undefined ? formatNumber( @@ -89,13 +102,13 @@ export function MarketDataCard() { 0, tokenData?.decimals ) - : NO_DATA; - const totalHolders = holders?.total ? formatNumber(holders.total) : NO_DATA; - const price = tokenData?.currentPrice ? formatUsdValue(tokenData.currentPrice) : NO_DATA; - const marketCap = tokenData?.marketCap ? formatUsdValue(tokenData?.marketCap) : NO_DATA; + : undefined; + const totalHolders = holders?.total ? formatNumber(holders.total) : undefined; + const price = tokenData?.currentPrice ? formatUsdValue(tokenData.currentPrice) : undefined; + const marketCap = tokenData?.marketCap ? formatUsdValue(tokenData?.marketCap) : undefined; const volume = tokenData?.tradingVolume24h ? formatUsdValue(tokenData?.tradingVolume24h) - : NO_DATA; + : undefined; return ( Market data - - - - - - + + + + + + ); } @@ -126,38 +151,43 @@ export const TokenIdOverview = () => { return ( - - - Recent transactions - - - + + + Recent transactions + + + ); }; diff --git a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx index ea3937a25..ecc7b4714 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; import { TxTabsTrigger } from '@/app/txid/[txId]/redesign/TxTabs'; import { AvailableFunctions } from '@/app/txid/[txId]/redesign/function-called/AvailableFunctions'; diff --git a/src/app/tokens/utils.ts b/src/app/tokens/utils.ts index c5f554a3e..26b3b5f32 100644 --- a/src/app/tokens/utils.ts +++ b/src/app/tokens/utils.ts @@ -1,6 +1,10 @@ import { FtBasicMetadataResponse } from '@hirosystems/token-metadata-api-client'; -import { sbtcContractAddress } from '../token/[tokenId]/consts'; +import { + LEGIT_SBTC_DERIVATIVES, + RISKY_TOKENS, + sbtcContractAddress, +} from '../token/[tokenId]/consts'; export const referencesSBTC = ( tokenName: FtBasicMetadataResponse['name'], @@ -11,9 +15,22 @@ export const referencesSBTC = ( } return tokenName.toLowerCase().includes('sbtc') || tokenSymbol.toLowerCase().includes('sbtc'); }; + export const isSBTC = (contractId: string) => { if (!contractId) { return false; } return contractId === sbtcContractAddress; }; + +export function showSBTCTokenAlert(tokenName: string, tokenSymbol: string, contractId: string) { + return ( + referencesSBTC(tokenName, tokenSymbol) && + !isSBTC(contractId) && + !LEGIT_SBTC_DERIVATIVES.includes(contractId) + ); +} + +export function showRiskyTokenAlert(contractId: string) { + return RISKY_TOKENS.includes(contractId); +} diff --git a/src/app/txid/[txId]/redesign/Alert.tsx b/src/app/txid/[txId]/redesign/Alert.tsx index 90e474473..bc61cace4 100644 --- a/src/app/txid/[txId]/redesign/Alert.tsx +++ b/src/app/txid/[txId]/redesign/Alert.tsx @@ -1,12 +1,12 @@ -"use client"; +'use client'; import { TransactionStatus as TransactionStatusEnum } from '@/common/constants/constants'; import { getTransactionStatus } from '@/common/utils/transactions'; import { Alert } from '@/components/ui/alert'; import { Link } from '@/ui/Link'; import { Text } from '@/ui/Text'; -import { Stack } from '@chakra-ui/react'; -import { Clock, Question, WarningDiamond, XCircle } from '@phosphor-icons/react'; +import { Flex, Stack } from '@chakra-ui/react'; +import { Clock, Question, Warning, WarningDiamond, XCircle } from '@phosphor-icons/react'; import { MempoolTransaction, Transaction } from '@stacks/stacks-blockchain-api-types'; @@ -196,8 +196,6 @@ export function SimilarTokenAlert() { description={ "Be cautious of tokens with names similar to well-known projects, as they may be duplicates or imitations. Verify the token's official contract address and confirm its legitimacy before any interaction." } - alertBg="colors.feedback.yellow-200" - alertIconColor="colors.feedback.yellow-700" /> ); } @@ -209,8 +207,9 @@ export function RiskyTokenAlert() { description={ 'This token may be a scam. Engaging with unverified tokens could result in loss of funds.' } - alertBg="colors.feedback.yellow-200" - alertIconColor="colors.feedback.yellow-700" + icon={} + alertIconColor="iconError" + bg={{ base: 'feedback.red-150', _dark: 'transactionStatus.failed' }} /> ); } @@ -218,21 +217,28 @@ export function RiskyTokenAlert() { export function NotSBTCTokenAlert() { return ( - This is not{' '} + + + This is not + - the official sBTC token - {' '} - and may be a scam. Engaging with unverified tokens could result in loss of funds. - + + the official sBTC token + + + + and may be a scam. Engaging with unverified tokens could result in loss of funds. + + } - alertBg="colors.feedback.yellow-200" - alertIconColor="colors.feedback.yellow-700" + icon={} + alertIconColor="iconError" + bg={{ base: 'feedback.red-150', _dark: 'transactionStatus.failed' }} /> ); } @@ -240,9 +246,11 @@ export function NotSBTCTokenAlert() { export function Sip10Alert() { return ( } /> ); } diff --git a/src/common/components/id-pages/Overview.tsx b/src/common/components/id-pages/Overview.tsx index 4ac0f9a7a..8d2eb0086 100644 --- a/src/common/components/id-pages/Overview.tsx +++ b/src/common/components/id-pages/Overview.tsx @@ -1,14 +1,44 @@ +import { + DEFAULT_BUTTON_STYLING, + DEFAULT_ICON_STYLING, +} from '@/app/txid/[txId]/redesign/tx-summary/SummaryItem'; +import { CopyButtonRedesign } from '@/common/components/CopyButton'; import { Text } from '@/ui/Text'; -import { Stack } from '@chakra-ui/react'; +import { Flex, Stack } from '@chakra-ui/react'; import { ReactNode } from 'react'; -export const StackingCardItem = ({ label, value }: { label: string; value: ReactNode }) => { +export const StackingCardItem = ({ + label, + value, + copyValue, +}: { + label: string; + value: ReactNode; + copyValue?: string; +}) => { return ( {label} - {value} + + {value} + {copyValue && ( + + )} + ); }; diff --git a/src/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers.tsx b/src/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers.tsx index 87c44f9e2..bdf1a05f8 100644 --- a/src/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers.tsx +++ b/src/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers.tsx @@ -68,7 +68,20 @@ export function FungibleTokenCellRenderer(value: FungibleTokenTableTokenColumnDa ); } -export const TokenImage = ({ url, alt, ...props }: { url: string; alt: string }) => { +export const TokenImage = ({ + url, + alt, + height, + width, + borderRadius, + ...props +}: { + url: string; + alt: string; + height?: number; + width?: number; + borderRadius?: string; +}) => { const [error, setError] = useState(false); const imageUrl = useMemo(() => { @@ -91,13 +104,16 @@ export const TokenImage = ({ url, alt, ...props }: { url: string; alt: string }) return ( { setError(true); }} alt={alt} + style={{ + borderRadius, + }} {...props} /> ); diff --git a/src/common/components/table/table-examples/HoldersTable.tsx b/src/common/components/table/table-examples/HoldersTable.tsx index 96d856c9e..bd89d6dee 100644 --- a/src/common/components/table/table-examples/HoldersTable.tsx +++ b/src/common/components/table/table-examples/HoldersTable.tsx @@ -1,8 +1,8 @@ 'use client'; -import { getHoldersQueryKey, useHolders } from '@/common/queries/useHolders'; import { GenericResponseType } from '@/common/hooks/useInfiniteQueryResult'; import { THIRTY_SECONDS } from '@/common/queries/query-stale-time'; +import { getHoldersQueryKey, useHolders } from '@/common/queries/useHolders'; import { calculateHoldingPercentage, formatHoldingPercentage, diff --git a/src/common/utils/fungible-token-utils.tsx b/src/common/utils/fungible-token-utils.tsx index 47f93ee2f..57038e836 100644 --- a/src/common/utils/fungible-token-utils.tsx +++ b/src/common/utils/fungible-token-utils.tsx @@ -53,7 +53,9 @@ export function calculateHoldingPercentage( if (t <= BigInt(0) || b < BigInt(0)) return undefined; // Multiply before dividing to preserve precision, then convert to number - const percentage = Number((b * bigintPow(BigInt(10), precision)) / t) / 100; // 2 decimals by default + const scale = bigintPow(BigInt(10), precision); + const percentageScaled = (b * BigInt(100) * scale) / t; + const percentage = Number(percentageScaled) / Number(scale); return parseFloat(percentage.toFixed(precision)); } catch { // Fallback for cases where balance/totalSupply aren't integer-like diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 78bfca759..afcbf89cd 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -1,4 +1,4 @@ -import { Alert as ChakraAlert, Icon } from '@chakra-ui/react'; +import { Alert as ChakraAlert, Icon, Stack } from '@chakra-ui/react'; import * as React from 'react'; import { CloseButton } from './close-button'; @@ -30,7 +30,7 @@ export const Alert = React.forwardRef(function Alert ...rest } = props; return ( - + {startElement} {icon && ( @@ -40,8 +40,10 @@ export const Alert = React.forwardRef(function Alert )} - {title && {title}} - {description && {description}} + + {title && {title}} + {description && {description}} + {endElement} {closable && ( From 462eb11d3955c45ae2c37d671ec9b49eac8db2b9 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Fri, 7 Nov 2025 14:14:08 -0600 Subject: [PATCH 5/7] feat: wp --- .../[tokenId]/redesign/TokenIdOverview.tsx | 80 +++++++++++++------ src/app/txid/[txId]/redesign/Alert.tsx | 17 ++-- src/common/components/id-pages/Overview.tsx | 2 +- .../table/table-examples/HoldersTable.tsx | 2 +- src/common/utils/fungible-token-utils.tsx | 2 +- src/components/ui/alert.tsx | 6 +- 6 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx index 23154cd1f..a154e7e72 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx @@ -41,7 +41,9 @@ export const TokenIdOverviewTable = () => { value={tokenId} valueRenderer={value => ( - {value} + + {value} + )} showCopyButton @@ -52,7 +54,9 @@ export const TokenIdOverviewTable = () => { value={txId} valueRenderer={value => ( - {value} + + {value} + )} showCopyButton @@ -145,33 +149,15 @@ export function MarketDataCard() { ); } -export const TokenIdOverview = () => { +function MobileTokenIdOverview() { const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); return ( - - - - - - - - - + + + + + { disablePagination /> + + ); +} + +function DesktopTokenIdOverview() { + const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); + + return ( + + + + + + + + Recent transactions + + + + + + ); +} + +export const TokenIdOverview = () => { + return ( + <> + + + + ); }; diff --git a/src/app/txid/[txId]/redesign/Alert.tsx b/src/app/txid/[txId]/redesign/Alert.tsx index bc61cace4..cb125c49a 100644 --- a/src/app/txid/[txId]/redesign/Alert.tsx +++ b/src/app/txid/[txId]/redesign/Alert.tsx @@ -5,7 +5,7 @@ import { getTransactionStatus } from '@/common/utils/transactions'; import { Alert } from '@/components/ui/alert'; import { Link } from '@/ui/Link'; import { Text } from '@/ui/Text'; -import { Flex, Stack } from '@chakra-ui/react'; +import { Stack } from '@chakra-ui/react'; import { Clock, Question, Warning, WarningDiamond, XCircle } from '@phosphor-icons/react'; import { MempoolTransaction, Transaction } from '@stacks/stacks-blockchain-api-types'; @@ -219,22 +219,19 @@ export function NotSBTCTokenAlert() { - - This is not - + + This is not  - + the official sBTC token - - and may be a scam. Engaging with unverified tokens could result in loss of funds. - - +   and may be a scam. Engaging with unverified tokens could result in loss of funds. + } icon={} alertIconColor="iconError" diff --git a/src/common/components/id-pages/Overview.tsx b/src/common/components/id-pages/Overview.tsx index 8d2eb0086..7514b6d19 100644 --- a/src/common/components/id-pages/Overview.tsx +++ b/src/common/components/id-pages/Overview.tsx @@ -18,7 +18,7 @@ export const StackingCardItem = ({ }) => { return ( - + {label} diff --git a/src/common/components/table/table-examples/HoldersTable.tsx b/src/common/components/table/table-examples/HoldersTable.tsx index bd89d6dee..2803be733 100644 --- a/src/common/components/table/table-examples/HoldersTable.tsx +++ b/src/common/components/table/table-examples/HoldersTable.tsx @@ -142,7 +142,7 @@ export function HoldersTable({ const adjustedBalance = getFtDecimalAdjustedBalance(holder.balance, decimals); const adjustedTotalSupply = getFtDecimalAdjustedBalance(totalSupply, decimals); const formattedBalance = formatNumber(adjustedBalance, 0, decimals); - const holdingPercentage = calculateHoldingPercentage(adjustedBalance, adjustedTotalSupply, 6); + const holdingPercentage = calculateHoldingPercentage(adjustedBalance, adjustedTotalSupply); const formattedHoldingPercentage = holdingPercentage ? formatHoldingPercentage(holdingPercentage) : undefined; diff --git a/src/common/utils/fungible-token-utils.tsx b/src/common/utils/fungible-token-utils.tsx index 57038e836..3b87309c4 100644 --- a/src/common/utils/fungible-token-utils.tsx +++ b/src/common/utils/fungible-token-utils.tsx @@ -39,7 +39,7 @@ export function getTokenImageUrlFromTokenMetadata(tokenMetadata: Metadata): stri export function calculateHoldingPercentage( balance: string | number | bigint | undefined, totalSupply: string | number | bigint | undefined, - precision: number = 4 + precision: number = 100 ): number | undefined { if (balance === undefined || totalSupply === undefined) { return undefined; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index afcbf89cd..3bce60d03 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -30,16 +30,16 @@ export const Alert = React.forwardRef(function Alert ...rest } = props; return ( - + {startElement} {icon && ( - + {icon} )} - + {title && {title}} {description && {description}} From 13e367f5c27b1a5b2cf7bed52f22a0a689771b79 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Mon, 10 Nov 2025 13:53:47 -0600 Subject: [PATCH 6/7] feat: wp --- src/app/token/[tokenId]/redesign/TokenIdOverview.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx index a154e7e72..88c9db13e 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx @@ -186,6 +186,7 @@ function DesktopTokenIdOverview() { templateColumns={'75% 25%'} templateRows={'auto auto'} columnGap={2.5} + alignItems="start" hideBelow="lg" className="desktop-token-id-overview" > From 90adc690d55cacc9aafcf66e9c29eb8820c2c289 Mon Sep 17 00:00:00 2001 From: nick-stacks Date: Wed, 19 Nov 2025 17:15:34 -0600 Subject: [PATCH 7/7] feat: added special token id page for sbtc --- next.config.js | 5 + src/api/data-fetchers.ts | 1 + .../[principal]/redesign/TokenImage.tsx | 6 +- src/app/token/[tokenId]/PageClient.tsx | 12 + src/app/token/[tokenId]/consts.ts | 2 +- src/app/token/[tokenId]/page-data.ts | 2 +- src/app/token/[tokenId]/page.tsx | 1 - src/app/token/[tokenId]/redesign/SbtcFaq.tsx | 80 +++++++ .../[tokenId]/redesign/SbtcTokenIdPage.tsx | 42 ++++ .../[tokenId]/redesign/TokenIdHeader.tsx | 66 ++++- .../[tokenId]/redesign/TokenIdOverview.tsx | 98 +++++++- .../token/[tokenId]/redesign/TokenStats.tsx | 157 ++++++++++++ .../[tokenId]/redesign/TotalSupplyCard.tsx | 97 ++++++++ src/common/components/GlowWrapper.tsx | 22 ++ src/common/components/ReverseAccordion.tsx | 225 ++++++++++++++++++ src/common/components/SBTCToken.tsx | 10 + .../table/table-examples/TxsTable.tsx | 4 +- src/common/constants/env.ts | 3 +- src/common/hooks/useResizeObserver.ts | 34 +++ src/common/types/lunarCrush.ts | 5 +- src/common/utils/string-utils.ts | 1 + src/common/utils/utils.ts | 1 + src/ui/icons/sBTCIcon.tsx | 6 +- src/ui/icons/sBTCTokenIcon.tsx | 131 ++++++++++ src/ui/theme/borderRadius.ts | 2 + src/ui/theme/colors.ts | 2 +- src/ui/theme/recipes/AccordionRecipe.ts | 68 ++++++ src/ui/theme/recipes/ButtonRecipe.ts | 21 ++ src/ui/theme/semanticTokens.ts | 8 + src/ui/theme/sizes.ts | 8 + src/ui/theme/space.ts | 14 ++ src/ui/theme/theme.ts | 2 + 32 files changed, 1107 insertions(+), 29 deletions(-) create mode 100644 src/app/token/[tokenId]/redesign/SbtcFaq.tsx create mode 100644 src/app/token/[tokenId]/redesign/SbtcTokenIdPage.tsx create mode 100644 src/app/token/[tokenId]/redesign/TokenStats.tsx create mode 100644 src/app/token/[tokenId]/redesign/TotalSupplyCard.tsx create mode 100644 src/common/components/GlowWrapper.tsx create mode 100644 src/common/components/ReverseAccordion.tsx create mode 100644 src/common/components/SBTCToken.tsx create mode 100644 src/common/hooks/useResizeObserver.ts create mode 100644 src/ui/icons/sBTCTokenIcon.tsx create mode 100644 src/ui/theme/recipes/AccordionRecipe.ts diff --git a/next.config.js b/next.config.js index 519869370..b17c28f2a 100644 --- a/next.config.js +++ b/next.config.js @@ -29,6 +29,11 @@ const nextConfig = { destination: '/', permanent: true, }, + { + source: '/sbtc', + destination: '/token/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token?chain=mainnet', + permanent: true, + }, ]; }, experimental: { diff --git a/src/api/data-fetchers.ts b/src/api/data-fetchers.ts index 8b5671660..d1a565979 100644 --- a/src/api/data-fetchers.ts +++ b/src/api/data-fetchers.ts @@ -174,6 +174,7 @@ export async function fetchTokenDataFromLunarCrush( if (!response || response?.error) { throw new Error('Error fetching token data from Lunar Crush'); } + return response; } catch (error) { logError( new Error('Error fetching token data from Lunar Crush'), diff --git a/src/app/address/[principal]/redesign/TokenImage.tsx b/src/app/address/[principal]/redesign/TokenImage.tsx index 4154796e9..b5d60d831 100644 --- a/src/app/address/[principal]/redesign/TokenImage.tsx +++ b/src/app/address/[principal]/redesign/TokenImage.tsx @@ -38,12 +38,14 @@ export const TokenImage = ({ height, width, addGlow, + borderRadius, }: { url: string; alt: string; height: number; width: number; addGlow?: boolean; + borderRadius?: string; }) => { const [imageUrl, setImageUrl] = useState(encodeURI(decodeURI(url))); const [badImage, setBadImage] = useState(false); @@ -69,7 +71,7 @@ export const TokenImage = ({ alt={alt} style={{ filter: 'blur(9px)', - borderRadius: '16px', + borderRadius: borderRadius || '16px', objectFit: 'cover', // ensures the image maintains its aspect ratio while covering the entire container, cropping if necessary to maintain the 1:1 ratio }} /> @@ -96,7 +98,7 @@ export const TokenImage = ({ }} alt={alt} style={{ - borderRadius: '16px', + borderRadius: borderRadius || '16px', }} /> diff --git a/src/app/token/[tokenId]/PageClient.tsx b/src/app/token/[tokenId]/PageClient.tsx index c3ad33a7c..4e3aaa61d 100644 --- a/src/app/token/[tokenId]/PageClient.tsx +++ b/src/app/token/[tokenId]/PageClient.tsx @@ -1,11 +1,23 @@ +'use client'; + +import { useTokenIdPageData } from '@/app/token/[tokenId]/redesign/context/TokenIdPageContext'; +import { isSBTC } from '@/app/tokens/utils'; import { Sip10Alert } from '@/app/txid/[txId]/redesign/Alert'; import { Stack } from '@chakra-ui/react'; +import { SbtcTokenIdPage } from './redesign/SbtcTokenIdPage'; import { TokenAlert } from './redesign/TokenAlert'; import { TokenIdHeader } from './redesign/TokenIdHeader'; import { TokenIdTabs } from './redesign/TokenIdTabs'; export default function TokenIdPageRedesign() { + const { tokenId } = useTokenIdPageData(); + const isSbtc = isSBTC(tokenId); + + if (isSbtc) { + return ; + } + return ( diff --git a/src/app/token/[tokenId]/consts.ts b/src/app/token/[tokenId]/consts.ts index 337ed147f..013e66015 100644 --- a/src/app/token/[tokenId]/consts.ts +++ b/src/app/token/[tokenId]/consts.ts @@ -1,4 +1,4 @@ -export const sbtcDepositAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4'; +export const sbtcDepositAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token'; export const sbtcContractAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token'; export const sbtcWidthdrawlContractAddress = 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-withdrawal'; diff --git a/src/app/token/[tokenId]/page-data.ts b/src/app/token/[tokenId]/page-data.ts index 1043cbb3e..83bc18849 100644 --- a/src/app/token/[tokenId]/page-data.ts +++ b/src/app/token/[tokenId]/page-data.ts @@ -105,7 +105,7 @@ export async function getTokenDataFromLunarCrush( return tokenData; } catch (error) { - logError(error as Error, 'getTokenInfoFromLunarCrush', { tokenId }); + logError(error as Error, 'getTokenDataFromLunarCrush', { tokenId }); return undefined; } } diff --git a/src/app/token/[tokenId]/page.tsx b/src/app/token/[tokenId]/page.tsx index 42537f438..ff3a77e1e 100644 --- a/src/app/token/[tokenId]/page.tsx +++ b/src/app/token/[tokenId]/page.tsx @@ -116,7 +116,6 @@ export default async function (props: { holders = handleSettledResult(holdersResult, 'Failed to fetch holders'); tokenData = mergeTokenData(tokenDataFromStacksApi, tokenDataFromLunarCrush, holders, tokenId); - txBlockTime = tx && isConfirmedTx(tx) ? tx.block_time : undefined; diff --git a/src/app/token/[tokenId]/redesign/SbtcFaq.tsx b/src/app/token/[tokenId]/redesign/SbtcFaq.tsx new file mode 100644 index 000000000..76d32db88 --- /dev/null +++ b/src/app/token/[tokenId]/redesign/SbtcFaq.tsx @@ -0,0 +1,80 @@ +import { ReverseAccordion, ReverseAccordionItem } from '@/common/components/ReverseAccordion'; +import { + AccordionItem, + AccordionItemContent, + AccordionItemTrigger, + AccordionRoot, +} from '@/components/ui/accordion'; +import { Button } from '@/ui/Button'; +import { Text } from '@/ui/Text'; +import { Flex, Icon, Stack } from '@chakra-ui/react'; +import { ArrowUpRight, Plus, X } from '@phosphor-icons/react'; +import { useState } from 'react'; + +export const items: ReverseAccordionItem[] = [ + { + title: 'What is sBTC?', + text: 'sBTC is a decentralized, 1:1 Bitcoin-backed asset on Stacks that unlocks Bitcoin for DeFi and smart contracts. It enables users to earn yield, access lending, and trade on decentralized exchanges, all with 100% Bitcoin finality. Secured by a decentralized network of signers-not a single entity-sBTC operates directly on the Bitcoin main chain, making its transactions resistant to censorship.', + link: ' https://sbtc.stacks.co/', + linkLabel: 'Learn more', + }, + { + title: 'How can I get sBTC?', + text: "You can mint sBTC by depositing BTC through the sBTC bridge app. Connect a non-custodial wallet like Leather or Xverse, enter the BTC amount and your Stacks address, sign the transaction, and once confirmed, you'll receive sBTC", + link: ' https://sbtc.stacks.co/', + linkLabel: 'Get sBTC', + }, +]; + +export function SbtcFaqReverseAccordion() { + return ; +} + +export function SbtcFaqAccordion() { + const [expandedIndex, setExpandedIndex] = useState(null); + + return ( + { + setExpandedIndex(Number(value.value[0])); + }} + variant="primary" + > + + {items.map((item, index) => { + const isExpanded = expandedIndex === index; + return ( + + + + {item.title} + + {isExpanded ? : } + + + + + + {item.text} + + + + + ); + })} + + + ); +} diff --git a/src/app/token/[tokenId]/redesign/SbtcTokenIdPage.tsx b/src/app/token/[tokenId]/redesign/SbtcTokenIdPage.tsx new file mode 100644 index 000000000..e81e37e3d --- /dev/null +++ b/src/app/token/[tokenId]/redesign/SbtcTokenIdPage.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { Sip10Alert } from '@/app/txid/[txId]/redesign/Alert'; +import { Box, Grid, Stack } from '@chakra-ui/react'; + +import { SbtcFaqAccordion, SbtcFaqReverseAccordion } from './SbtcFaq'; +import { TokenAlert } from './TokenAlert'; +import { TokenIdHeader } from './TokenIdHeader'; +import { TokenIdTabs } from './TokenIdTabs'; +import { TokenStats } from './TokenStats'; +import { TotalSupplyCard } from './TotalSupplyCard'; + +export function SbtcTokenIdPage() { + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx index d5506eb88..18d147bab 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx @@ -1,17 +1,19 @@ 'use client'; -import { showRiskyTokenAlert, showSBTCTokenAlert } from '@/app/tokens/utils'; -import { TokenImage } from '@/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers'; +import { TokenImage } from '@/app/address/[principal]/redesign/TokenImage'; +import { isSBTC, showRiskyTokenAlert, showSBTCTokenAlert } from '@/app/tokens/utils'; import { useIsInViewport } from '@/common/hooks/useIsInViewport'; import { getAssetNameParts } from '@/common/utils/utils'; import { DefaultBadge, DefaultBadgeIcon, DefaultBadgeLabel } from '@/ui/Badge'; +import { Button } from '@/ui/Button'; import { Text, TextProps } from '@/ui/Text'; import { Tooltip } from '@/ui/Tooltip'; import { Box, Flex, Icon, Stack, useClipboard } from '@chakra-ui/react'; -import { Coin, Warning } from '@phosphor-icons/react'; +import { ArrowUpRight, Coin, SealCheck, Warning } from '@phosphor-icons/react'; import { motion } from 'motion/react'; import { forwardRef, useRef } from 'react'; +import { VERIFIED_TOKENS } from '../consts'; import { useTokenIdPageData } from './context/TokenIdPageContext'; const BORDER_WIDTH = 1; @@ -137,13 +139,32 @@ const WarningIcon = ({ return null; }; +const VerifiedIcon = ({ contractId }: { contractId: string }) => { + const icon = ( + + + + ); + + if (VERIFIED_TOKENS.includes(contractId)) { + return icon; + } + + return null; +}; + export const TokenIdHeaderUnminimized = forwardRef< HTMLDivElement, { name: string; symbol: string; imageUrl: string; contractId: string } >(({ name, symbol, imageUrl, contractId }, ref) => { + const isSbtc = isSBTC(contractId); return ( - + + + {isSbtc && ( + + )} @@ -173,9 +212,14 @@ export const TokenIdHeaderMinimized = ({ imageUrl: string; contractId: string; }) => { + const isSbtc = isSBTC(contractId); return ( - + + diff --git a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx index 88c9db13e..ac86cb0b4 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdOverview.tsx @@ -1,16 +1,31 @@ +'use client'; + +import { isSBTC } from '@/app/tokens/utils'; import { TabsContentContainer } from '@/app/txid/[txId]/redesign/TxTabs'; -import { SummaryItem } from '@/app/txid/[txId]/redesign/tx-summary/SummaryItem'; -import { TokenLink, TxLink } from '@/common/components/ExplorerLinks'; +import { + DEFAULT_BUTTON_STYLING, + DEFAULT_ICON_STYLING, + SummaryItem, +} from '@/app/txid/[txId]/redesign/tx-summary/SummaryItem'; +import { CopyButtonRedesign } from '@/common/components/CopyButton'; +import { AddressLink, TokenLink, TxLink } from '@/common/components/ExplorerLinks'; import { StackingCardItem } from '@/common/components/id-pages/Overview'; +import { EllipsisText } from '@/common/components/table/CommonTableCellRenderers'; import { AddressTxsTable } from '@/common/components/table/table-examples/AddressTxsTable'; import { DEFAULT_OVERVIEW_TAB_TABLE_PAGE_SIZE } from '@/common/components/table/table-examples/consts'; import { formatNumber, formatUsdValue } from '@/common/utils/string-utils'; import { formatTimestamp } from '@/common/utils/time-utils'; -import { getFtDecimalAdjustedBalance } from '@/common/utils/utils'; -import { SimpleTag } from '@/ui/Badge'; +import { + getAssetNameParts, + getContractName, + getFtDecimalAdjustedBalance, +} from '@/common/utils/utils'; +import { Badge, SimpleTag } from '@/ui/Badge'; import { Text } from '@/ui/Text'; -import { Grid, Stack, Table } from '@chakra-ui/react'; +import ClarityIcon from '@/ui/icons/ClarityIcon'; +import { Flex, Grid, Icon, Stack, Table } from '@chakra-ui/react'; +import { sbtcContractAddress, sbtcDepositAddress, sbtcWidthdrawlContractAddress } from '../consts'; import { useTokenIdPageData } from './context/TokenIdPageContext'; export const TokenIdOverviewTable = () => { @@ -89,7 +104,7 @@ const NO_DATA = ( ); export function MarketDataCard() { - const { tokenData, holders } = useTokenIdPageData(); + const { tokenData, holders, tokenId } = useTokenIdPageData(); const circulatingSupply = holders?.total_supply && tokenData?.decimals !== undefined @@ -149,6 +164,72 @@ export function MarketDataCard() { ); } +function ContractBadge({ address }: { address: string }) { + return ( + + + + + + + + + {getContractName(address)} + + + + + + + ); +} + +function SbtcProtocolContractCard() { + return ( + + + Protocol contracts + + + + + + ); +} + function MobileTokenIdOverview() { const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); @@ -180,6 +261,9 @@ function MobileTokenIdOverview() { function DesktopTokenIdOverview() { const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); + const { address, contract } = getAssetNameParts(tokenId || ''); + const contractId = `${address}.${contract}`; + const isSbtc = isSBTC(contractId); return ( - + {isSbtc ? : } ); } diff --git a/src/app/token/[tokenId]/redesign/TokenStats.tsx b/src/app/token/[tokenId]/redesign/TokenStats.tsx new file mode 100644 index 000000000..0922d609e --- /dev/null +++ b/src/app/token/[tokenId]/redesign/TokenStats.tsx @@ -0,0 +1,157 @@ +import { useTokenIdPageData } from '@/app/token/[tokenId]/redesign/context/TokenIdPageContext'; +import { ScrollIndicator } from '@/common/components/ScrollIndicator'; +import { formatNumber, formatUsdValue } from '@/common/utils/string-utils'; +import { getFtDecimalAdjustedBalance } from '@/common/utils/utils'; +import { Button } from '@/ui/Button'; +import { Text } from '@/ui/Text'; +import { Flex, FlexProps, Icon, Stack } from '@chakra-ui/react'; +import { ArrowUpRight, TrendDown, TrendUp } from '@phosphor-icons/react'; +import { ReactNode } from 'react'; + +export function TokenStats() { + const { tokenData, holders } = useTokenIdPageData(); + const { + marketCap, + totalSupply, + circulatingSupply, + currentPrice, + decimals, + tradingVolume24h, + priceChangePercentage24h, + } = tokenData || {}; + + const adjustedCirculatingSupply = + holders?.total_supply && decimals !== undefined + ? formatNumber(getFtDecimalAdjustedBalance(holders?.total_supply, decimals), 0, 2) + : undefined; + + const adjustedTotalSupply = + totalSupply && decimals !== undefined + ? formatNumber(getFtDecimalAdjustedBalance(totalSupply, decimals), 0, 2) + : undefined; + + const adjustedHolders = holders?.total ? formatNumber(holders.total) : undefined; + const adjustedPrice = currentPrice ? formatUsdValue(currentPrice) : undefined; + const adjustedMarketCap = marketCap ? formatUsdValue(marketCap) : undefined; + + return ( + + + + + + + + + {adjustedPrice} + + 0 + ? 'var(--stacks-colors-green-500)' + : 'var(--stacks-colors-red-500)' + } + > + {priceChangePercentage24h !== undefined && priceChangePercentage24h > 0 ? ( + + ) : ( + + )} + + + } + /> + + + + + + + + + + + + + ); +} + +function TokenStatsCard({ children, ...props }: { children: ReactNode } & FlexProps) { + return ( + + {children} + + ); +} + +function TokenStatsCardContent({ + title, + value, + secondaryValue, +}: { + title: string; + value: ReactNode; + secondaryValue?: ReactNode; +}) { + return ( + + + {title} + + {typeof value === 'string' ? ( + + {value} + + ) : ( + value + )} + {secondaryValue && ( + + {secondaryValue} + + )} + + ); +} diff --git a/src/app/token/[tokenId]/redesign/TotalSupplyCard.tsx b/src/app/token/[tokenId]/redesign/TotalSupplyCard.tsx new file mode 100644 index 000000000..a49889270 --- /dev/null +++ b/src/app/token/[tokenId]/redesign/TotalSupplyCard.tsx @@ -0,0 +1,97 @@ +import { SBTCToken } from '@/common/components/SBTCToken'; +import { formatNumber, formatUsdValue } from '@/common/utils/string-utils'; +import { getFtDecimalAdjustedBalance } from '@/common/utils/utils'; +import { Text } from '@/ui/Text'; +import { Box, Stack } from '@chakra-ui/react'; + +import { useTokenIdPageData } from './context/TokenIdPageContext'; + +function ClusterOfTokens() { + return ( + + + + + + + + ); +} + +export function TotalSupplyCard() { + const { tokenData } = useTokenIdPageData(); + const { totalSupply, marketCap, decimals } = tokenData || {}; + const adjustedTotalSupply = + totalSupply && decimals !== undefined + ? formatNumber(getFtDecimalAdjustedBalance(totalSupply, decimals), 0, 2) + : undefined; + + const adjustedMarketCap = marketCap ? formatUsdValue(marketCap) : undefined; + + return ( + + + Total supply + + + {adjustedTotalSupply} sBTC + + + ({adjustedMarketCap}) + + + + ); +} diff --git a/src/common/components/GlowWrapper.tsx b/src/common/components/GlowWrapper.tsx new file mode 100644 index 000000000..ff7decdbf --- /dev/null +++ b/src/common/components/GlowWrapper.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Box } from '@chakra-ui/react'; + +export const GlowWrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + + {children} + + + {children} + + + ); +}; diff --git a/src/common/components/ReverseAccordion.tsx b/src/common/components/ReverseAccordion.tsx new file mode 100644 index 000000000..f759a54c5 --- /dev/null +++ b/src/common/components/ReverseAccordion.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useResizeObserver } from '@/common/hooks/useResizeObserver'; +import { Button } from '@/ui/Button'; +import { Text } from '@/ui/Text'; +import { Box, Flex, Icon, Stack } from '@chakra-ui/react'; +import { ArrowUpRight, Plus, X } from '@phosphor-icons/react'; +import { createRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const DEFUALT_ITEM_HEIGHT_IN_PX = 40; // can make this custom +const DEFAULT_ITEM_OVERLAP_IN_PX = 8; +const DEFAULT_PX = 4; +const DEFAULT_PY = 3; +const DEFAULT_HOVER_RISE_IN_PX = 40; // How much the card rises when hovered +const EXTRA_RISE_TO_SHOW_CONTENT_IN_PX = DEFAULT_ITEM_OVERLAP_IN_PX / 2; +const MARGIN_FROM_TOP_IN_PX = 20; + +function ReverseAccordionItem({ + title, + text, + link, + linkLabel, + index, + setIsExpanded, + isExpanded, + accordionWidth, + cardHeightInPx, + top, + px, + py, +}: { + title: string; + text: string; + link: string; + linkLabel: string; + index: number; + setIsExpanded: (index: number, state: boolean) => void; + isExpanded: boolean; + accordionWidth: number; + cardHeightInPx: number; + top: number; + px?: number; + py?: number; +}) { + const [isHovered, setIsHovered] = useState(false); + const [contentHeight, setContentHeight] = useState(0); + const contentRef = useRef(null); + const containerRef = useRef(null); + const [rectTop, setRectTop] = useState(0); + const maxRise = useMemo( + () => rectTop - cardHeightInPx - MARGIN_FROM_TOP_IN_PX, + [rectTop, cardHeightInPx] + ); + + // Sets the content height + useEffect(() => { + if (contentRef.current) { + setContentHeight(contentRef.current.scrollHeight); + // Sets the rect top to the top of the content if the content is higher than the rect top + if (contentRef.current.getBoundingClientRect().top > rectTop) { + setRectTop(contentRef.current.getBoundingClientRect().top); + } + } + }, [text, isExpanded, isHovered, accordionWidth, rectTop]); + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={() => (isExpanded ? null : setIsExpanded(index, true))} + cursor={!isExpanded ? 'pointer' : 'default'} + transition="all 0.3s ease-out" + transform={ + isExpanded + ? `translateY(-${Math.min(contentHeight, maxRise) + (index === 0 ? 0 : EXTRA_RISE_TO_SHOW_CONTENT_IN_PX)}px)` + : isHovered + ? `translateY(-${DEFAULT_HOVER_RISE_IN_PX}px)` + : 'translateY(0)' + } + height={ + isExpanded + ? `${cardHeightInPx + Math.min(contentHeight, maxRise)}px` + : isHovered + ? `${cardHeightInPx + DEFAULT_HOVER_RISE_IN_PX}px` + : `${cardHeightInPx}px` + } + overflow="hidden" + zIndex={1000 + index} + boxShadow="0px -8px 10px -6px rgba(0, 0, 0, 0.1)" + > + <> + + {title} + (isExpanded ? setIsExpanded(index, false) : null)} + h={4} + w={4} + color="iconTertiary" + cursor="pointer" + > + {isExpanded ? : } + + + + + {text} + + + + + + ); +} + +export interface ReverseAccordionItem { + title: string; + text: string; + link: string; + linkLabel: string; +} + +function calculateReverseAccordionHeight( + numOfItems: number, + itemHeight: number, + itemOverlap: number +) { + return `${numOfItems * itemHeight - itemOverlap * (numOfItems - 1)}px`; +} + +export function ReverseAccordion({ + items, + itemHeight = DEFUALT_ITEM_HEIGHT_IN_PX, + itemOverlap = DEFAULT_ITEM_OVERLAP_IN_PX, +}: { + items: ReverseAccordionItem[]; + itemHeight?: number; + itemOverlap?: number; +}) { + const [totalHeightInPx, _] = useState( + calculateReverseAccordionHeight(items.length, itemHeight, itemOverlap) + ); + const containerRef = useRef(null); + const [expandedIndex, setExpandedIndex] = useState(null); + const itemTitleRefs = useRef[]>( + Array.from({ length: items.length }, () => createRef()) + ); + + const setIsExpanded = useCallback((index: number, state: boolean) => { + if (state) { + setExpandedIndex(index); + } else { + setExpandedIndex(null); + } + }, []); + + const { width } = useResizeObserver(containerRef); + + return ( + + {items.map((item, index) => ( + sum + (itemHeight - itemOverlap), 0)} + px={DEFAULT_PX} + py={DEFAULT_PY} + /> + ))} + + ); +} diff --git a/src/common/components/SBTCToken.tsx b/src/common/components/SBTCToken.tsx new file mode 100644 index 000000000..f9f59be49 --- /dev/null +++ b/src/common/components/SBTCToken.tsx @@ -0,0 +1,10 @@ +import { SBTCTokenIcon } from '@/ui/icons/sBTCTokenIcon'; +import { Icon, IconProps } from '@chakra-ui/react'; + +export const SBTCToken = (iconProps: IconProps) => { + return ( + + + + ); +}; diff --git a/src/common/components/table/table-examples/TxsTable.tsx b/src/common/components/table/table-examples/TxsTable.tsx index f068a574c..27b639299 100644 --- a/src/common/components/table/table-examples/TxsTable.tsx +++ b/src/common/components/table/table-examples/TxsTable.tsx @@ -5,8 +5,7 @@ import { TxPageFilters } from '@/app/transactions/page'; import { GenericResponseType } from '@/common/hooks/useInfiniteQueryResult'; import { THIRTY_SECONDS } from '@/common/queries/query-stale-time'; import { useConfirmedTransactions } from '@/common/queries/useConfirmedTransactionsInfinite'; -import { CompressedTxTableData } from '@/common/utils/transaction-utils'; -import { getAmount, getToAddress } from '@/common/utils/transaction-utils'; +import { CompressedTxTableData, getAmount, getToAddress } from '@/common/utils/transaction-utils'; import { validateStacksContractId } from '@/common/utils/utils'; import { Flex, Icon } from '@chakra-ui/react'; import { ArrowRight } from '@phosphor-icons/react'; @@ -26,7 +25,6 @@ import { TimestampCell, TimestampColumnHeader, TimestampTableMeta } from '../Tim import { UpdateTableBannerRow } from '../UpdateTableBannerRow'; import { IconCellRenderer, - TimeStampCellRenderer, TransactionTitleCellRenderer, TxLinkCellRenderer, TxTypeCellRenderer, diff --git a/src/common/constants/env.ts b/src/common/constants/env.ts index 5417ed73d..880ac7e78 100644 --- a/src/common/constants/env.ts +++ b/src/common/constants/env.ts @@ -31,7 +31,8 @@ export const DEVNET_PORT = process.env.NEXT_PUBLIC_EXPLORER_DEVNET_PORT || '8000 export const DEVNET_SERVER = process.env.NEXT_PUBLIC_EXPLORER_DEVNET_SERVER || 'http://localhost:3999'; -export const LUNAR_CRUSH_API_KEY = process.env.LUNAR_CRUSH_API_KEY || ''; +export const LUNAR_CRUSH_API_KEY = + process.env.LUNAR_CRUSH_API_KEY || process.env.NEXT_PUBLIC_LUNAR_CRUSH_API_KEY || ''; export const CMS_URL = process.env.CMS_URL ?? ''; diff --git a/src/common/hooks/useResizeObserver.ts b/src/common/hooks/useResizeObserver.ts new file mode 100644 index 000000000..7b97f414a --- /dev/null +++ b/src/common/hooks/useResizeObserver.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react'; + +interface Size { + width: number; + height: number; +} + +export function useResizeObserver(ref: React.RefObject): Size { + const [size, setSize] = useState({ width: 0, height: 0 }); + const observerRef = useRef(null); + + useEffect(() => { + if (ref.current) { + observerRef.current = new ResizeObserver(entries => { + for (let entry of entries) { + const { width, height } = entry.contentRect; + setSize({ width, height }); + } + }); + + observerRef.current.observe(ref.current); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [ref]); + + return size; +} + +export default useResizeObserver; diff --git a/src/common/types/lunarCrush.ts b/src/common/types/lunarCrush.ts index 0dfc29a65..df4b68ae8 100644 --- a/src/common/types/lunarCrush.ts +++ b/src/common/types/lunarCrush.ts @@ -24,6 +24,8 @@ export interface LunarCrushCoin { export interface LunarCrushCoinRedesign { config?: { coin: string; generated: number }; data?: { + alt_rank?: number; + galaxy_score?: number; id: number; name: string; symbol: string; @@ -32,12 +34,11 @@ export interface LunarCrushCoinRedesign { market_cap?: number; percent_change_24h?: number; percent_change_7d?: number; + percent_change_30d?: number; volume_24h?: number; max_supply?: number; circulating_supply?: number; close?: number; - galaxy_score?: number; - alt_rank?: number; volatility?: number; market_cap_rank?: number; }; diff --git a/src/common/utils/string-utils.ts b/src/common/utils/string-utils.ts index 2118db3d6..bdf651f41 100644 --- a/src/common/utils/string-utils.ts +++ b/src/common/utils/string-utils.ts @@ -47,6 +47,7 @@ export function formatUsdValue( return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', + currencyDisplay: 'narrowSymbol', minimumFractionDigits, maximumFractionDigits, }).format(value); diff --git a/src/common/utils/utils.ts b/src/common/utils/utils.ts index 0181b3446..92d60c6c2 100644 --- a/src/common/utils/utils.ts +++ b/src/common/utils/utils.ts @@ -379,6 +379,7 @@ export function getTxContractId(tx: Transaction | MempoolTransaction) { export const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', + currencyDisplay: 'narrowSymbol', }); export function isNumeric(value: string): boolean { diff --git a/src/ui/icons/sBTCIcon.tsx b/src/ui/icons/sBTCIcon.tsx index fe1090996..42b44f95b 100644 --- a/src/ui/icons/sBTCIcon.tsx +++ b/src/ui/icons/sBTCIcon.tsx @@ -5,18 +5,18 @@ const weights = new Map([ [ 'regular', <> - + diff --git a/src/ui/icons/sBTCTokenIcon.tsx b/src/ui/icons/sBTCTokenIcon.tsx new file mode 100644 index 000000000..63cd260ab --- /dev/null +++ b/src/ui/icons/sBTCTokenIcon.tsx @@ -0,0 +1,131 @@ +import { IconBase, IconWeight } from '@phosphor-icons/react'; +import { ReactElement, forwardRef } from 'react'; + +interface SBTCTokenIconWeightProps { + stopColor: string; +} + +const createWeight = ({ stopColor }: SBTCTokenIconWeightProps): ReactElement => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +const weights = new Map ReactElement>([ + ['regular', createWeight], +]); + +interface SBTCTokenIconProps extends SBTCTokenIconWeightProps { + weight?: IconWeight; +} + +export const SBTCTokenIcon = forwardRef((props, ref) => { + const { stopColor, weight = 'regular', ...restProps } = props; + const weightFn = weights.get(weight); + const weightElement = weightFn?.({ stopColor }); + + if (!weightElement) return null; + + return ( + + ); +}); + +SBTCTokenIcon.displayName = 'SBTCTokenIcon'; diff --git a/src/ui/theme/borderRadius.ts b/src/ui/theme/borderRadius.ts index 30731b458..09132b124 100644 --- a/src/ui/theme/borderRadius.ts +++ b/src/ui/theme/borderRadius.ts @@ -25,6 +25,8 @@ export const NEW_BORDER_RADIUS = { xl: { value: '16px' }, xxl: { value: '18px' }, '2xl': { value: '20px' }, + '3xl': { value: '24px' }, + '4xl': { value: '32px' }, }, }; diff --git a/src/ui/theme/colors.ts b/src/ui/theme/colors.ts index 1b864fe00..c5c19ff19 100644 --- a/src/ui/theme/colors.ts +++ b/src/ui/theme/colors.ts @@ -106,7 +106,7 @@ export const NEW_COLORS = { 'stacks-200': { value: '#FFC2A8' }, 'stacks-300': { value: '#FFA57F' }, 'stacks-400': { value: '#FF8761' }, - 'stacks-500': { value: '#FC6432' }, + 'stacks-500': { value: 'color(display-p3 0.99 0.39 0.20)' }, 'stacks-600': { value: '#CC4900' }, 'stacks-700': { value: '#9C310D' }, 'bitcoin-100': { value: '#FFDFC1' }, diff --git a/src/ui/theme/recipes/AccordionRecipe.ts b/src/ui/theme/recipes/AccordionRecipe.ts new file mode 100644 index 000000000..6060789a7 --- /dev/null +++ b/src/ui/theme/recipes/AccordionRecipe.ts @@ -0,0 +1,68 @@ +import { accordionAnatomy } from '@ark-ui/react'; +import { defineSlotRecipe } from '@chakra-ui/react'; + +export const accordionSlotRecipe = defineSlotRecipe({ + className: 'chakra-accordion', + slots: [...accordionAnatomy.keys(), 'itemBody'], + base: {}, + + variants: { + variant: { + primary: { + itemTrigger: { + px: 4, + py: 3, + bg: 'surfaceFourth', + borderRadius: 'redesign.lg', + _open: { + borderBottomRadius: 'none', + }, + }, + itemContent: { + bg: 'surfaceFourth', + py: 0, + px: 'var(--accordion-padding-x)', + borderBottomRadius: 'redesign.lg', + }, + item: { + borderRadius: 'redesign.lg', + }, + }, + }, + + size: { + sm: { + root: { + '--accordion-padding-x': 'spacing.3', + '--accordion-padding-y': 'spacing.2', + }, + itemTrigger: { + textStyle: 'sm', + py: 'var(--accordion-padding-y)', + }, + }, + md: { + root: { + '--accordion-padding-x': 'spacing.4', + '--accordion-padding-y': 'spacing.2', + }, + itemTrigger: { + textStyle: 'md', + py: 'var(--accordion-padding-y)', + }, + }, + lg: { + root: { + '--accordion-padding-x': 'spacing.4.5', + '--accordion-padding-y': 'spacing.2.5', + }, + itemTrigger: { + textStyle: 'lg', + py: 'var(--accordion-padding-y)', + }, + }, + }, + }, + + defaultVariants: {}, +}); diff --git a/src/ui/theme/recipes/ButtonRecipe.ts b/src/ui/theme/recipes/ButtonRecipe.ts index 18f8118d9..0f7634bcd 100644 --- a/src/ui/theme/recipes/ButtonRecipe.ts +++ b/src/ui/theme/recipes/ButtonRecipe.ts @@ -148,6 +148,27 @@ export const buttonRecipe = defineRecipe({ }, }, }, + redesignStacks: { + borderRadius: 'redesign.md', + color: 'textPrimary', + bg: { + base: '{colors.accent.stacks-500}', + _dark: '{colors.accent.stacks-500}', + }, + _hover: { + bg: '{colors.accent.stacks-600}', + }, + _disabled: { + color: { + base: '{colors.neutral.sand-500}', + _dark: '{colors.neutral.sand-300}', + }, + bg: { + base: '{colors.accent.stacks-300}', + _dark: '{colors.accent.stacks-600}', + }, + }, + }, unstyled: { border: 'none', bg: 'none', diff --git a/src/ui/theme/semanticTokens.ts b/src/ui/theme/semanticTokens.ts index 9aaee5655..ea2381da2 100644 --- a/src/ui/theme/semanticTokens.ts +++ b/src/ui/theme/semanticTokens.ts @@ -262,6 +262,14 @@ export const CURRENT_SEMANTIC_TOKENS = { stacksNameAndLogo: { value: { base: '{colors.neutral.sand-1000}', _dark: '{colors.neutral.sand-50}' }, }, + sbtcToken: { + stopColor: { + value: { + base: 'color(display-p3 0.988 0.392 0.196)', + _dark: 'color(display-p3 0.349 0.341 0.329)', + }, + }, + }, }, }; diff --git a/src/ui/theme/sizes.ts b/src/ui/theme/sizes.ts index 9449e73aa..069495045 100644 --- a/src/ui/theme/sizes.ts +++ b/src/ui/theme/sizes.ts @@ -22,7 +22,15 @@ export const CURRENT_SIZES = { 3.5: { value: '0.875rem' }, // 14px 4.5: { value: '1.125rem' }, // 18px 5.5: { value: '1.375rem' }, // 22px + 6: { value: '1.5rem' }, // 24px + 7: { value: '1.75rem' }, // 28px + 8: { value: '2rem' }, // 32px + 8.5: { value: '2.125rem' }, // 34px + 9: { value: '2.25rem' }, // 36px + 10: { value: '2.5rem' }, // 40px 10.5: { value: '2.625rem' }, // 42px + 11: { value: '2.75rem' }, // 44px + 12: { value: '3rem' }, // 48px 13: { value: '3.25rem' }, // 52px 13.5: { value: '3.375rem' }, // 54px 14: { value: '3.5rem' }, // 56px diff --git a/src/ui/theme/space.ts b/src/ui/theme/space.ts index d890f87ec..d8d931ea9 100644 --- a/src/ui/theme/space.ts +++ b/src/ui/theme/space.ts @@ -48,14 +48,28 @@ export const NEW_SPACE = { 4.5: { value: '1.125rem' }, 5: { value: '1.25rem' }, // 20px 6: { value: '1.5rem' }, // 24px + 7: { value: '1.75rem' }, // 28px + 7.5: { value: '1.875rem' }, // 30px 8: { value: '2rem' }, // 32px + 8.5: { value: '2.125rem' }, // 34px + 9: { value: '2.25rem' }, // 36px 10: { value: '2.5rem' }, // 40px + 11: { value: '2.75rem' }, // 44px + 11.25: { value: '2.875rem' }, // 46px + 11.5: { value: '2.875rem' }, // 46px 12: { value: '3rem' }, // 48px + 13: { value: '3.25rem' }, // 52px + 14: { value: '3.5rem' }, // 56px 15: { value: '3.75rem' }, // 60px 16: { value: '4rem' }, // 64px + 17: { value: '4.25rem' }, // 68px 18: { value: '4.5rem' }, // 72px 20: { value: '5rem' }, // 80px 24: { value: '6rem' }, // 96px + 25: { value: '6.25rem' }, // 100px + 26: { value: '6.5rem' }, // 104px + 27: { value: '6.75rem' }, // 108px + 27.5: { value: '6.875rem' }, // 108px 28: { value: '7rem' }, // 112px 32: { value: '8rem' }, // 128px 36: { value: '9rem' }, // 144px diff --git a/src/ui/theme/theme.ts b/src/ui/theme/theme.ts index 0089d59f0..d983e57b9 100644 --- a/src/ui/theme/theme.ts +++ b/src/ui/theme/theme.ts @@ -11,6 +11,7 @@ import { FONT_WEIGHTS } from './fontWeights'; import { FONTS } from './fonts'; import { LETTER_SPACINGS } from './letterSpacings'; import { LINEHEIGHTS } from './lineHeights'; +import { accordionSlotRecipe } from './recipes/AccordionRecipe'; import { alertSlotRecipe } from './recipes/AlertRecipe'; import { badgeRecipe } from './recipes/BadgeRecipe'; import { buttonRecipe } from './recipes/ButtonRecipe'; @@ -62,6 +63,7 @@ const themeConfig = { popover: popoverSlotRecipe, alert: alertSlotRecipe, select: selectSlotRecipe, + accordion: accordionSlotRecipe, }, tokens: { colors: { ...COLORS, ...NEW_COLORS },