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/__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/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 defe0a0d6..4e3aaa61d 100644 --- a/src/app/token/[tokenId]/PageClient.tsx +++ b/src/app/token/[tokenId]/PageClient.tsx @@ -1,159 +1,29 @@ 'use client'; -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 { 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 ( - <> - - {!!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]/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]/consts.ts b/src/app/token/[tokenId]/consts.ts index d9a4be4c7..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'; @@ -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]/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-data.ts b/src/app/token/[tokenId]/page-data.ts index 462b07ca6..83bc18849 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, @@ -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; } } @@ -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 3e32f51aa..ff3a77e1e 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, @@ -120,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; @@ -159,7 +154,7 @@ export default async function (props: { holders={holders} numFunctions={numFunctions} > - + ); } 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/TokenAlert.tsx b/src/app/token/[tokenId]/redesign/TokenAlert.tsx new file mode 100644 index 000000000..fa9617561 --- /dev/null +++ b/src/app/token/[tokenId]/redesign/TokenAlert.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { showRiskyTokenAlert, showSBTCTokenAlert } from '@/app/tokens/utils'; +import { NotSBTCTokenAlert, RiskyTokenAlert } from '@/app/txid/[txId]/redesign/Alert'; +import { getAssetNameParts } from '@/common/utils/utils'; + +import { useTokenIdPageData } from './context/TokenIdPageContext'; + +export function TokenAlert() { + const { assetId, tokenData } = useTokenIdPageData(); + const { address, contract } = getAssetNameParts(assetId || ''); + const contractId = `${address}.${contract}`; + + if (showSBTCTokenAlert(tokenData?.name || '', tokenData?.symbol || '', contractId)) { + return ; + } + + if (showRiskyTokenAlert(contractId)) { + return ; + } + + return null; +} diff --git a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx index 8b7a8ae22..18d147bab 100644 --- a/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx +++ b/src/app/token/[tokenId]/redesign/TokenIdHeader.tsx @@ -1,19 +1,19 @@ -import { TokenImage } from '@/common/components/table/fungible-tokens-table/FungibleTokensTableCellRenderers'; +'use client'; + +import { TokenImage } from '@/app/address/[principal]/redesign/TokenImage'; +import { isSBTC, showRiskyTokenAlert, showSBTCTokenAlert } from '@/app/tokens/utils'; 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 { Button } from '@/ui/Button'; 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 { 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; @@ -57,7 +57,7 @@ const Badge = ({ ); }; -const TokenNameBadgeUnminimized = ({ name }: { name: string }) => { +const TokenNameUnminimized = ({ name }: { name: string }) => { return name ? ( {name} @@ -65,6 +65,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" />} @@ -97,40 +104,122 @@ 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; +}; + +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 } ->(({ name, symbol, imageUrl }, ref) => { + { name: string; symbol: string; imageUrl: string; contractId: string } +>(({ name, symbol, imageUrl, contractId }, ref) => { + const isSbtc = isSBTC(contractId); return ( - + - - + + + + + {isSbtc && ( + + )} ); }); -export const TokenIdHeaderMinimized = ({ tokenId }: { tokenId: string }) => { +export const TokenIdHeaderMinimized = ({ + name, + symbol, + imageUrl, + contractId, +}: { + name: string; + symbol: string; + imageUrl: string; + contractId: string; +}) => { + const isSbtc = isSBTC(contractId); return ( { alignItems="center" > - - + + + + + + + + @@ -155,6 +257,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); @@ -165,6 +269,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 bd6da6f56..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 = () => { @@ -26,7 +41,14 @@ export const TokenIdOverviewTable = () => { } + valueRenderer={value => ( + + )} showCopyButton /> { value={tokenId} valueRenderer={value => ( - {value} + + {value} + )} showCopyButton @@ -44,8 +68,10 @@ export const TokenIdOverviewTable = () => { label="Contract deploy transaction" value={txId} valueRenderer={value => ( - - {value} + + + {value} + )} showCopyButton @@ -55,7 +81,14 @@ export const TokenIdOverviewTable = () => { } + valueRenderer={value => ( + + )} showCopyButton /> )} @@ -65,13 +98,13 @@ export const TokenIdOverviewTable = () => { }; const NO_DATA = ( - + No data available ); export function MarketDataCard() { - const { tokenData, holders } = useTokenIdPageData(); + const { tokenData, holders, tokenId } = useTokenIdPageData(); const circulatingSupply = holders?.total_supply && tokenData?.decimals !== undefined @@ -80,7 +113,7 @@ export function MarketDataCard() { 0, tokenData?.decimals ) - : NO_DATA; + : undefined; const totalSupply = tokenData?.totalSupply && tokenData?.decimals !== undefined ? formatNumber( @@ -88,13 +121,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 - - - - - - + + + + + + ); } -export const TokenIdOverview = () => { - const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); +function ContractBadge({ address }: { address: string }) { + return ( + + + + + + + + + {getContractName(address)} + + + + + + + ); +} +function SbtcProtocolContractCard() { return ( - + + Protocol contracts + + + + + + ); +} + +function MobileTokenIdOverview() { + const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); + + return ( + + + + + + + Recent transactions + + + + + ); +} + +function DesktopTokenIdOverview() { + const { initialAddressRecentTransactionsData, tokenId } = useTokenIdPageData(); + const { address, contract } = getAssetNameParts(tokenId || ''); + const contractId = `${address}.${contract}`; + const isSbtc = isSBTC(contractId); + + return ( + + @@ -149,14 +290,17 @@ export const TokenIdOverview = () => { /> - - - + + {isSbtc ? : } ); +} + +export const TokenIdOverview = () => { + return ( + <> + + + + ); }; diff --git a/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx b/src/app/token/[tokenId]/redesign/TokenIdTabs.tsx index b5e92bd35..ecc7b4714 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'; @@ -93,12 +95,6 @@ export const TokenIdTabs = () => { isActive={selectedTab === TokenIdPageTab.Source} onClick={() => setSelectedTab(TokenIdPageTab.Source)} /> - setSelectedTab(TokenIdPageTab.Source)} - /> + + + + + + + + {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/app/token/[tokenId]/utils.ts b/src/app/token/[tokenId]/utils.ts index 8de5a727c..82cb8b5c4 100644 --- a/src/app/token/[tokenId]/utils.ts +++ b/src/app/token/[tokenId]/utils.ts @@ -1,38 +1,7 @@ import { validateStacksContractId } from '@/common/utils/utils'; -import { ArrowSquareOut, DiscordLogo, TelegramLogo, TwitterLogo } from '@phosphor-icons/react'; -import StxIcon from '../../../ui/icons/StxIcon'; import { RISKY_NFT_RULES } from './consts'; -export const isExplorerLink = (url: string) => { - 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/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) { +import { + LEGIT_SBTC_DERIVATIVES, + RISKY_TOKENS, + sbtcContractAddress, +} from '../token/[tokenId]/consts'; + +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; }; + +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 b80810fae..cb125c49a 100644 --- a/src/app/txid/[txId]/redesign/Alert.tsx +++ b/src/app/txid/[txId]/redesign/Alert.tsx @@ -1,10 +1,12 @@ +'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 { Clock, Question, Warning, WarningDiamond, XCircle } from '@phosphor-icons/react'; import { MempoolTransaction, Transaction } from '@stacks/stacks-blockchain-api-types'; @@ -174,7 +176,7 @@ export function getTxAlert(tx: Transaction | MempoolTransaction) { return alertContent; } -export function SuspiciousTokenAlert() { +export function UnknownOrNewlyIssuedTokenAlert() { return ( + ); +} + +export function RiskyTokenAlert() { + return ( + } + alertIconColor="iconError" + bg={{ base: 'feedback.red-150', _dark: 'transactionStatus.failed' }} + /> + ); +} + +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. + + } + icon={} + alertIconColor="iconError" + bg={{ base: 'feedback.red-150', _dark: 'transactionStatus.failed' }} + /> + ); +} + +export function Sip10Alert() { + return ( + } /> ); } 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/id-pages/Overview.tsx b/src/common/components/id-pages/Overview.tsx index 4ac0f9a7a..7514b6d19 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 9035f5609..2803be733 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 '@/app/token/[tokenId]/Tabs/data/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, @@ -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/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/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 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/fungible-token-utils.tsx b/src/common/utils/fungible-token-utils.tsx index 47f93ee2f..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; @@ -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/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/components/ui/alert.tsx b/src/components/ui/alert.tsx index 78bfca759..3bce60d03 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,18 +30,20 @@ export const Alert = React.forwardRef(function Alert ...rest } = props; return ( - + {startElement} {icon && ( - + {icon} )} - - {title && {title}} - {description && {description}} + + + {title && {title}} + {description && {description}} + {endElement} {closable && ( 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 },