diff --git a/packages/common/src/messages/walletMessages.ts b/packages/common/src/messages/walletMessages.ts index abe3726a571..24b2b25c548 100644 --- a/packages/common/src/messages/walletMessages.ts +++ b/packages/common/src/messages/walletMessages.ts @@ -70,5 +70,8 @@ export const walletMessages = { // YourCoins messages yourCoins: 'Your Coins', - buySell: 'Buy/Sell' + buySell: 'Buy/Sell', + buy: 'Buy', + send: 'Send', + receive: 'Receive' } diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index 1c292cec1d8..d941b46af36 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -69,6 +69,7 @@ export const PURCHASES_PAGE = '/payments/purchases' export const SALES_PAGE = '/payments/sales' export const WITHDRAWALS_PAGE = '/payments/withdrawals' export const TRANSACTION_HISTORY_PAGE = '/wallet/transaction-history' +export const ASSET_DETAIL_PAGE = '/wallet/:slug' export const WALLET_PAGE = '/wallet' export const PRIVATE_KEY_EXPORTER_SETTINGS_PAGE = '/settings/export-private-key' export const DEV_TOOLS_PAGE = '/dev-tools' @@ -272,6 +273,7 @@ export const orderedRoutes = [ PAYMENTS_PAGE, AUDIO_PAGE, WALLET_AUDIO_PAGE, + ASSET_DETAIL_PAGE, WALLET_PAGE, REWARDS_PAGE, SETTINGS_PAGE, diff --git a/packages/web/src/app/web-player/WebPlayer.jsx b/packages/web/src/app/web-player/WebPlayer.jsx index 640f91854df..e89ac767083 100644 --- a/packages/web/src/app/web-player/WebPlayer.jsx +++ b/packages/web/src/app/web-player/WebPlayer.jsx @@ -51,6 +51,7 @@ import { USDCBalanceFetcher } from 'components/usdc-balance-fetcher/USDCBalanceF import { useEnvironment } from 'hooks/useEnvironment' import { MAIN_CONTENT_ID, MainContentContext } from 'pages/MainContentContext' import { AiAttributedTracksPage } from 'pages/ai-attributed-tracks-page' +import { AssetDetailPage } from 'pages/asset-detail-page/AssetDetailPage' import { AudioPage } from 'pages/audio-page/AudioPage' import { ChatPageProvider } from 'pages/chat-page/ChatPageProvider' import CollectionPage from 'pages/collection-page/CollectionPage' @@ -117,7 +118,7 @@ const { HISTORY_PAGE, DASHBOARD_PAGE, AUDIO_PAGE, - WALLET_AUDIO_PAGE, + ASSET_DETAIL_PAGE, REWARDS_PAGE, UPLOAD_PAGE, UPLOAD_ALBUM_PAGE, @@ -212,8 +213,14 @@ const validSearchCategories = [ initializeSentry() const WebPlayer = (props) => { - const { isProduction, history, location, mainContentRef, setMainContentRef } = - props + const { + isProduction, + history, + location, + mainContentRef, + setMainContentRef, + isArtistCoinsEnabled + } = props const dispatch = useDispatch() @@ -692,19 +699,25 @@ const WebPlayer = (props) => { /> { + return isArtistCoinsEnabled ? ( + + ) : ( + + ) + }} /> @@ -974,12 +987,16 @@ const FeatureFlaggedWebPlayer = (props) => { const { isEnabled: isSearchExploreEnabled } = useFeatureFlag( FeatureFlags.SEARCH_EXPLORE ) + const { isEnabled: isArtistCoinsEnabled } = useFeatureFlag( + FeatureFlags.ARTIST_COINS + ) const { isProduction } = useEnvironment() return ( ) diff --git a/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx b/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx new file mode 100644 index 00000000000..88c2cb60326 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/AssetDetailContent.tsx @@ -0,0 +1,24 @@ +import { Flex } from '@audius/harmony' + +import { AssetInfoSection } from './components/AssetInfoSection' +import { AssetInsights } from './components/AssetInsights' +import { BalanceSection } from './components/BalanceSection' +import { AssetDetailProps } from './types' + +const LEFT_SECTION_WIDTH = '704px' +const RIGHT_SECTION_WIDTH = '360px' + +export const AssetDetailContent = ({ mint }: AssetDetailProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/packages/web/src/pages/asset-detail-page/AssetDetailPage.tsx b/packages/web/src/pages/asset-detail-page/AssetDetailPage.tsx new file mode 100644 index 00000000000..15b6f210ba5 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/AssetDetailPage.tsx @@ -0,0 +1,47 @@ +import { Redirect, useParams } from 'react-router-dom' + +import { Header } from 'components/header/desktop/Header' +import Page from 'components/page/Page' +import WalletModal from 'pages/audio-page/WalletModal' + +import { useAssetDetailTabs } from './AssetDetailTabs' +import { ACCEPTED_ROUTES } from './constants' +import { AcceptedRouteKey } from './types' + +export const AssetDetailPage = () => { + const { slug } = useParams<{ slug: string }>() + + if (!slug || !(slug in ACCEPTED_ROUTES)) { + return + } + + // At this point, we know slug is a valid key + const typedSlug = slug as AcceptedRouteKey + const routeConfig = ACCEPTED_ROUTES[typedSlug] + const title = routeConfig.title + + return +} + +type AssetDetailPageContentProps = { + mint: AcceptedRouteKey + title: string +} + +const AssetDetailPageContent = ({ + mint, + title +}: AssetDetailPageContentProps) => { + const { tabs, body } = useAssetDetailTabs({ mint }) + + const header = ( +
+ ) + + return ( + + + {body} + + ) +} diff --git a/packages/web/src/pages/asset-detail-page/AssetDetailTabs.tsx b/packages/web/src/pages/asset-detail-page/AssetDetailTabs.tsx new file mode 100644 index 00000000000..8b372091fed --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/AssetDetailTabs.tsx @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react' + +import useTabs from 'hooks/useTabs/useTabs' +import { AudioWalletTransactions } from 'pages/audio-page/AudioWalletTransactions' + +import { AssetDetailContent } from './AssetDetailContent' +import { AcceptedRouteKey } from './types' + +export enum AssetDetailTabType { + HOME = 'home', + TRANSACTIONS = 'transactions' +} + +const messages = { + home: 'Home', + transactions: 'Transactions' +} + +type UseAssetDetailTabsProps = { + mint: AcceptedRouteKey +} + +export const useAssetDetailTabs = ({ mint }: UseAssetDetailTabsProps) => { + const [selectedTab, setSelectedTab] = useState(AssetDetailTabType.HOME) + + const tabElements = [ + , + + ] + + const handleTabChange = useCallback((_from: string, to: string) => { + setSelectedTab(to as AssetDetailTabType) + }, []) + + return useTabs({ + isMobile: false, + tabs: [ + { + text: messages.home, + label: AssetDetailTabType.HOME + }, + { + text: messages.transactions, + label: AssetDetailTabType.TRANSACTIONS + } + ], + selectedTabLabel: selectedTab, + elements: tabElements, + didChangeTabsFrom: handleTabChange + }) +} diff --git a/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx b/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx new file mode 100644 index 00000000000..8cf9ea59a2d --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/components/AssetInfoSection.tsx @@ -0,0 +1,120 @@ +import { WidthSizes } from '@audius/common/models' +import { Flex, Paper, Text, useTheme } from '@audius/harmony' + +import UserBadges from 'components/user-badges/UserBadges' +import { useCoverPhoto } from 'hooks/useCoverPhoto' + +import { ACCEPTED_ROUTES, ASSET_INFO_SECTION_MESSAGES } from '../constants' +import { AssetDetailProps } from '../types' + +const BANNER_HEIGHT = 120 + +const BannerSection = ({ mint }: AssetDetailProps) => { + const { name, userId, icon: TokenIcon } = ACCEPTED_ROUTES[mint] + + const { cornerRadius } = useTheme() + + const { image: coverPhoto } = useCoverPhoto({ + userId, + size: WidthSizes.SIZE_640 + }) + + return ( + + + + {ASSET_INFO_SECTION_MESSAGES.default.createdBy} + + + + {TokenIcon ? ( + + ) : null} + + + {name} + + + + + + + ) +} + +export const AssetInfoSection = ({ mint }: AssetDetailProps) => { + const { title } = ACCEPTED_ROUTES[mint] + const CTAIcon = ASSET_INFO_SECTION_MESSAGES[mint].ctaIcon + + return ( + + + + + + + {ASSET_INFO_SECTION_MESSAGES.default.whatIs(title)} + + + + + {ASSET_INFO_SECTION_MESSAGES[mint].description.map((text, index) => ( + + {text} + + ))} + + + + + + + + {ASSET_INFO_SECTION_MESSAGES[mint].cta} + + + + + ) +} diff --git a/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx b/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx new file mode 100644 index 00000000000..14abcbf7ea8 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/components/AssetInsights.tsx @@ -0,0 +1,131 @@ +import { Flex, IconCaretDown, IconCaretUp, Paper, Text } from '@audius/harmony' + +import { AssetDetailProps } from '../types' + +const messages = { + title: 'Insights', + pricePerCoin: 'Price per coin', + holdersOnAudius: 'Holders on Audius', + uniqueHolders: 'Unique Holders', + volume24hr: 'Volume (24hr)', + totalTransfers: 'Total Transfers' +} + +type MetricData = { + value: string + label: string + change?: { + value: string + isPositive: boolean + } +} + +const MOCK_METRICS: MetricData[] = [ + { + value: '$0.082', + label: messages.pricePerCoin, + change: { + value: '0.005', + isPositive: false + } + }, + { + value: '12.6K', + label: messages.holdersOnAudius, + change: { + value: '0.5%', + isPositive: true + } + }, + { + value: '37.7K', + label: messages.uniqueHolders, + change: { + value: '0.01%', + isPositive: true + } + }, + { + value: '5.9M', + label: messages.volume24hr, + change: { + value: '1.68%', + isPositive: true + } + }, + { + value: '514K', + label: messages.totalTransfers + } +] + +const MetricRow = ({ metric }: { metric: MetricData }) => { + const changeColor = metric.change?.isPositive ? 'premium' : 'danger' + + return ( + + + + {metric.value} + + + {metric.label} + + + + {metric.change ? ( + + + {metric.change.value} + + + {metric.change.isPositive ? ( + + ) : ( + + )} + + + ) : null} + + ) +} + +export const AssetInsights = ({ mint: _mint }: AssetDetailProps) => { + return ( + + + + {messages.title} + + + + {MOCK_METRICS.map((metric) => ( + + ))} + + ) +} diff --git a/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx b/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx new file mode 100644 index 00000000000..f1ad64b17d9 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/components/BalanceSection.tsx @@ -0,0 +1,183 @@ +import { ComponentType } from 'react' + +import { useAudioBalance } from '@audius/common/api' +import { useFormattedAudioBalance } from '@audius/common/hooks' +import { walletMessages } from '@audius/common/messages' +import { + tokenDashboardPageActions, + useAddCashModal, + useBuySellModal +} from '@audius/common/store' +import { Button, Flex, Paper, Text, useTheme } from '@audius/harmony' +import { useDispatch } from 'react-redux' + +import { useModalState } from 'common/hooks/useModalState' +import { useIsMobile } from 'hooks/useIsMobile' + +import { ACCEPTED_ROUTES } from '../constants' +import { AssetDetailProps } from '../types' + +type BalanceStateProps = { + title: string + icon?: ComponentType + onBuy?: () => void + onReceive?: () => void + onSend?: () => void +} + +const TokenIcon = ({ icon: Icon }: { icon?: ComponentType }) => { + if (!Icon) return null + return +} + +const ZeroBalanceState = ({ + title, + icon, + onBuy, + onReceive +}: BalanceStateProps) => { + return ( + <> + + + + {title} + + + + + + + + ) +} + +const HasBalanceState = ({ + title, + icon, + onBuy, + onSend, + onReceive +}: BalanceStateProps) => { + const { motion } = useTheme() + const { + audioBalanceFormatted, + audioDollarValue, + isAudioBalanceLoading, + isAudioPriceLoading + } = useFormattedAudioBalance() + + const isLoading = isAudioBalanceLoading || isAudioPriceLoading + + return ( + <> + + + + + + {audioBalanceFormatted} + + + {title} + + + + {audioDollarValue} + + + + + + + + + + + + ) +} + +export const BalanceSection = ({ mint }: AssetDetailProps) => { + const { totalBalance } = useAudioBalance() + const { title, icon } = ACCEPTED_ROUTES[mint] + + // Modal hooks + const { onOpen: openBuySellModal } = useBuySellModal() + const { onOpen: openAddCashModal } = useAddCashModal() + const [, openTransferDrawer] = useModalState('TransferAudioMobileWarning') + + // Redux and mobile detection + const dispatch = useDispatch() + const isMobile = useIsMobile() + + // Action destructuring + const { pressReceive, pressSend } = tokenDashboardPageActions + + // Handler functions + const handleBuySell = () => { + // Has balance - show buy/sell modal + openBuySellModal() + } + + const handleAddCash = () => { + // No balance - show add cash modal (uses Coinflow) + openAddCashModal() + } + + const handleReceive = () => { + if (isMobile) { + openTransferDrawer(true) + } else { + dispatch(pressReceive()) + } + } + + const handleSend = () => { + if (isMobile) { + openTransferDrawer(true) + } else { + dispatch(pressSend()) + } + } + + return ( + + + {!totalBalance ? ( + + ) : ( + + )} + + + ) +} diff --git a/packages/web/src/pages/asset-detail-page/constants.ts b/packages/web/src/pages/asset-detail-page/constants.ts new file mode 100644 index 00000000000..e2dd8b72463 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/constants.ts @@ -0,0 +1,29 @@ +import { TOKEN_LISTING_MAP } from '@audius/common/store' +import { IconGift } from '@audius/harmony' + +import { TOKENS } from 'components/buy-sell-modal/constants' + +export const ACCEPTED_ROUTES = { + audio: { + title: '$AUDIO', + symbol: TOKEN_LISTING_MAP.AUDIO.symbol, + icon: TOKENS.AUDIO.icon, + name: TOKENS.AUDIO.name, + userId: 51 + } +} + +export const ASSET_INFO_SECTION_MESSAGES = { + default: { + createdBy: 'Created By', + whatIs: (asset: string) => `What is ${asset}?` + }, + audio: { + description: [ + '$AUDIO is the token that powers Audius. Earn $AUDIO for free by completing in-app challenges and winning weekly trending competitions.', + "You can use $AUDIO for tipping, and as your balance grows, you'll move up rewards tiers that unlock perks like flair, custom themes, and more. Holding $AUDIO also gives you a voice in community votes, helping shape Audius and the future of the music industry." + ], + cta: 'Browse Rewards', + ctaIcon: IconGift + } +} diff --git a/packages/web/src/pages/asset-detail-page/types.ts b/packages/web/src/pages/asset-detail-page/types.ts new file mode 100644 index 00000000000..91edbbc4435 --- /dev/null +++ b/packages/web/src/pages/asset-detail-page/types.ts @@ -0,0 +1,7 @@ +import { ACCEPTED_ROUTES } from './constants' + +export type AcceptedRouteKey = keyof typeof ACCEPTED_ROUTES + +export type AssetDetailProps = { + mint: AcceptedRouteKey +} diff --git a/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx b/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx index f2104923aa1..04dd7178acc 100644 --- a/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx +++ b/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx @@ -124,7 +124,7 @@ export const AudioWalletTransactions = () => { const isEmpty = audioTransactions.length === 0 return ( - + {isEmpty && !tableLoading ? (