diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 7d34239dfd2..b82a83b1e01 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -42,6 +42,7 @@ export * from './tan-query/lineups/useTrendingPlaylists' export * from './tan-query/lineups/useTrendingUnderground' export * from './tan-query/lineups/useTrackPageLineup' export * from './tan-query/lineups/useLineupQuery' +export * from './tan-query/lineups/useExclusiveTracks' // Notifications export * from './tan-query/notifications/useMarkNotificationsAsViewed' @@ -162,6 +163,7 @@ export * from './tan-query/wallets/useUSDCBalance' export * from './tan-query/wallets/useExternalWalletBalance' export * from './tan-query/wallets/useCoinBalance' export * from './tan-query/wallets/useCoinBalanceBreakdown' +export * from './tan-query/wallets/useUserBalanceHistory' export * from './tan-query/wallets/useSendCoins' export * from './tan-query/jupiter/useSwapCoins' export * from './tan-query/jupiter/useCoinExchangeRate' diff --git a/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts b/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts new file mode 100644 index 00000000000..ec84496766a --- /dev/null +++ b/packages/common/src/api/tan-query/lineups/useExclusiveTracks.ts @@ -0,0 +1,149 @@ +import { EntityType, OptionalId } from '@audius/sdk' +import { + InfiniteData, + useInfiniteQuery, + useQuery, + useQueryClient +} from '@tanstack/react-query' +import { useDispatch } from 'react-redux' + +import { transformAndCleanList, userTrackMetadataFromSDK } from '~/adapters' +import { useQueryContext } from '~/api/tan-query/utils' +import { PlaybackSource } from '~/models/Analytics' +import { ID } from '~/models/Identifiers' +import { + exclusiveTracksPageSelectors, + exclusiveTracksPageLineupActions +} from '~/store/pages' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, LineupData, QueryOptions } from '../types' +import { useCurrentUserId } from '../users/account/useCurrentUserId' +import { primeTrackData } from '../utils/primeTrackData' + +import { useLineupQuery } from './useLineupQuery' + +const DEFAULT_PAGE_SIZE = 10 + +type GateCondition = 'ungated' | 'usdc_purchase' | 'follow' | 'tip' | 'token' + +type UseExclusiveTracksArgs = { + userId: ID | null | undefined + gateConditions?: GateCondition[] + pageSize?: number + initialPageSize?: number +} + +export const getExclusiveTracksQueryKey = ({ + userId, + gateConditions = ['token'], + pageSize +}: UseExclusiveTracksArgs) => + [ + QUERY_KEYS.exclusiveTracks, + userId, + { gateConditions, pageSize } + ] as unknown as QueryKey> + +export const useExclusiveTracks = ( + { + userId, + gateConditions = ['token'], + pageSize = DEFAULT_PAGE_SIZE, + initialPageSize + }: UseExclusiveTracksArgs, + options?: QueryOptions +) => { + const { audiusSdk } = useQueryContext() + const { data: currentUserId } = useCurrentUserId() + const queryClient = useQueryClient() + const dispatch = useDispatch() + + const queryData = useInfiniteQuery({ + queryKey: getExclusiveTracksQueryKey({ + userId, + gateConditions, + pageSize + }), + initialPageParam: 0, + getNextPageParam: (lastPage: LineupData[], allPages) => { + if (lastPage.length < pageSize) return undefined + return allPages.length * pageSize + }, + queryFn: async ({ pageParam }) => { + const sdk = await audiusSdk() + const { data: tracks = [] } = await sdk.full.users.getTracksByUser({ + id: OptionalId.parse(userId)!, + userId: OptionalId.parse(currentUserId), + gateCondition: gateConditions as any, + limit: pageSize, + offset: pageParam + }) + + const processedTracks = transformAndCleanList( + tracks, + userTrackMetadataFromSDK + ) + primeTrackData({ tracks: processedTracks, queryClient }) + + // Update lineup when new data arrives + dispatch( + exclusiveTracksPageLineupActions.fetchLineupMetadatas( + pageParam, + pageSize, + false, + { items: processedTracks } + ) + ) + + return processedTracks.map((t) => ({ + id: t.track_id, + type: EntityType.TRACK + })) + }, + select: (data) => data?.pages.flat(), + ...options, + enabled: options?.enabled !== false && !!userId + }) + + return useLineupQuery({ + lineupData: queryData.data ?? [], + queryData, + queryKey: getExclusiveTracksQueryKey({ + userId, + gateConditions, + pageSize + }), + lineupActions: exclusiveTracksPageLineupActions, + lineupSelector: exclusiveTracksPageSelectors.getLineup, + playbackSource: PlaybackSource.EXCLUSIVE_TRACKS_PAGE, + pageSize, + initialPageSize + }) +} + +// Hook to get the count of exclusive tracks +export const useExclusiveTracksCount = (args: { + userId: ID | null | undefined + gateConditions?: GateCondition[] + enabled?: boolean +}) => { + const { userId, gateConditions = ['token'], enabled = true } = args + const { audiusSdk } = useQueryContext() + const { data: currentUserId } = useCurrentUserId() + + return useQuery({ + queryKey: [QUERY_KEYS.exclusiveTracksCount, userId, { gateConditions }], + queryFn: async () => { + const sdk = await audiusSdk() + const { data: count } = await sdk.full.users.getTracksCountByUser({ + id: OptionalId.parse(userId)!, + userId: OptionalId.parse(currentUserId), + gateCondition: gateConditions as any + }) + + return count ?? 0 + }, + enabled: enabled && !!userId + }) +} diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index d43568b68b8..64704335c6a 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -127,5 +127,7 @@ export const QUERY_KEYS = { walletSolBalance: 'walletSolBalance', launchpadConfig: 'launchpadConfig', externalWalletBalance: 'externalWalletBalance', - claimFee: 'claimFee' + claimFee: 'claimFee', + exclusiveTracks: 'exclusiveTracks', + exclusiveTracksCount: 'exclusiveTracksCount' } as const diff --git a/packages/common/src/api/tan-query/wallets/useUserBalanceHistory.ts b/packages/common/src/api/tan-query/wallets/useUserBalanceHistory.ts new file mode 100644 index 00000000000..8d30aa16611 --- /dev/null +++ b/packages/common/src/api/tan-query/wallets/useUserBalanceHistory.ts @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query' + +import { QueryOptions } from '../types' +import { useCurrentUserId } from '../users/account/useCurrentUserId' + +export type BalanceHistoryDataPoint = { + timestamp: number + balanceUsd: number +} + +type UseUserBalanceHistoryParams = { + userId?: number + enabled?: boolean +} + +/** + * Mock hook for getting user balance history (coins + USDC + AUDIO in USD). + * Returns hourly data points for the past week. + * TODO: Replace with real API call when backend is ready + */ +export const useUserBalanceHistory = ( + params: UseUserBalanceHistoryParams = {}, + options?: QueryOptions +) => { + const { data: currentUserId } = useCurrentUserId() + const userId = params.userId ?? currentUserId + + return useQuery({ + queryKey: ['userBalanceHistory', userId], + queryFn: async (): Promise => { + // Mock data: Generate hourly data points for the past week (7 days * 24 hours = 168 data points) + const now = Date.now() + const oneHour = 60 * 60 * 1000 + const oneWeek = 7 * 24 * oneHour + const startTime = now - oneWeek + + const dataPoints: BalanceHistoryDataPoint[] = [] + + // Generate realistic-looking balance data with some variance + const baseBalance = 5000 // Base balance in USD + let previousBalance = baseBalance + + for (let i = 0; i <= 168; i++) { + const timestamp = startTime + i * oneHour + + // Add some realistic variance (±2% per hour with trend) + const randomVariance = (Math.random() - 0.48) * 0.02 + const trendVariance = Math.sin(i / 20) * 0.01 // Slight upward/downward trend + const totalVariance = 1 + randomVariance + trendVariance + + const newBalance = previousBalance * totalVariance + previousBalance = newBalance + + dataPoints.push({ + timestamp, + balanceUsd: Math.round(newBalance * 100) / 100 // Round to 2 decimals + }) + } + + // Simulate network delay + await new Promise((resolve) => setTimeout(resolve, 300)) + + return dataPoints + }, + enabled: !!userId && (params.enabled ?? true) && (options?.enabled ?? true), + staleTime: 60000, // 1 minute + ...options + }) +} diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 7e7459306ac..7c36fbacf1f 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1527,7 +1527,8 @@ export enum PlaybackSource { EMBED_PLAYER = 'embed player', CHAT_TRACK = 'chat_track', CHAT_PLAYLIST_TRACK = 'chat_playlist_track', - SEARCH_PAGE = 'search page' + SEARCH_PAGE = 'search page', + EXCLUSIVE_TRACKS_PAGE = 'exclusive tracks page' } type PlaybackPlay = { diff --git a/packages/common/src/store/pages/exclusive-tracks/index.ts b/packages/common/src/store/pages/exclusive-tracks/index.ts new file mode 100644 index 00000000000..398099d8cfe --- /dev/null +++ b/packages/common/src/store/pages/exclusive-tracks/index.ts @@ -0,0 +1,4 @@ +export { default as exclusiveTracksPageReducer } from './slice' +export * as exclusiveTracksPageActions from './slice' +export * as exclusiveTracksPageLineupActions from './lineup/actions' +export * as exclusiveTracksPageSelectors from './selectors' diff --git a/packages/common/src/store/pages/exclusive-tracks/lineup/actions.ts b/packages/common/src/store/pages/exclusive-tracks/lineup/actions.ts new file mode 100644 index 00000000000..dbd15cc9aef --- /dev/null +++ b/packages/common/src/store/pages/exclusive-tracks/lineup/actions.ts @@ -0,0 +1,11 @@ +import { LineupActions } from '~/store/lineup/actions' + +export const PREFIX = 'EXCLUSIVE_TRACKS' + +class TracksActions extends LineupActions { + constructor() { + super(PREFIX) + } +} + +export const tracksActions = new TracksActions() diff --git a/packages/common/src/store/pages/exclusive-tracks/lineup/reducer.ts b/packages/common/src/store/pages/exclusive-tracks/lineup/reducer.ts new file mode 100644 index 00000000000..d28d9dfd72e --- /dev/null +++ b/packages/common/src/store/pages/exclusive-tracks/lineup/reducer.ts @@ -0,0 +1,25 @@ +import { RESET_SUCCEEDED, stripPrefix } from '~/store/lineup/actions' +import { initialLineupState } from '~/store/lineup/reducer' + +import { PREFIX } from './actions' + +export const initialState = { + ...initialLineupState, + prefix: PREFIX +} + +const actionsMap: { [key in string]: any } = { + [RESET_SUCCEEDED](_state: typeof initialState) { + const newState = initialState + return newState + } +} + +const tracks = (state = initialState, action: { type: string }) => { + const baseActionType = stripPrefix(PREFIX, action.type) + const matchingReduceFunction = actionsMap[baseActionType] + if (!matchingReduceFunction) return state + return matchingReduceFunction(state, action) +} + +export default tracks diff --git a/packages/common/src/store/pages/exclusive-tracks/selectors.ts b/packages/common/src/store/pages/exclusive-tracks/selectors.ts new file mode 100644 index 00000000000..3664f9fbda7 --- /dev/null +++ b/packages/common/src/store/pages/exclusive-tracks/selectors.ts @@ -0,0 +1,7 @@ +import { CommonState } from '~/store/commonStore' + +export const getBaseState = (state: CommonState) => state.pages.exclusiveTracks + +export const getLineup = (state: CommonState) => getBaseState(state).tracks + +export const getUserId = (state: CommonState) => getBaseState(state).page.userId diff --git a/packages/common/src/store/pages/exclusive-tracks/slice.ts b/packages/common/src/store/pages/exclusive-tracks/slice.ts new file mode 100644 index 00000000000..8c44b0253f1 --- /dev/null +++ b/packages/common/src/store/pages/exclusive-tracks/slice.ts @@ -0,0 +1,49 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { combineReducers } from 'redux' + +import { asLineup } from '~/store/lineup/reducer' + +import { ID } from '../../../models/Identifiers' + +import { PREFIX as exclusiveTracksPrefix } from './lineup/actions' +import exclusiveTracksReducer, { + initialState as initialLineupState +} from './lineup/reducer' + +type State = { + userId: ID | null + tracks: typeof initialLineupState +} + +const initialState: State = { + userId: null, + tracks: initialLineupState +} + +const slice = createSlice({ + name: 'application/pages/exclusiveTracks', + initialState, + reducers: { + reset: (state) => { + state.userId = null + }, + setUserId: (state, action: PayloadAction<{ userId: ID }>) => { + const { userId } = action.payload + state.userId = userId + } + } +}) + +const exclusiveTracksLineupReducer = asLineup( + exclusiveTracksPrefix, + exclusiveTracksReducer +) + +export const { reset, setUserId } = slice.actions + +export default combineReducers({ + page: slice.reducer, + tracks: exclusiveTracksLineupReducer +}) + +export const actions = slice.actions diff --git a/packages/common/src/store/pages/index.ts b/packages/common/src/store/pages/index.ts index 751ec286075..f612cba2b95 100644 --- a/packages/common/src/store/pages/index.ts +++ b/packages/common/src/store/pages/index.ts @@ -119,3 +119,14 @@ export * from './chat' export { default as premiumTracksPageLineupReducer } from './premium-tracks/lineup/reducer' export * as premiumTracksPageLineupSelectors from './premium-tracks/lineup/selectors' export { premiumTracksActions as premiumTracksPageLineupActions } from './premium-tracks/lineup/actions' + +export { + default as exclusiveTracksPageLineupReducer, + initialState as exclusiveTracksPageLineupInitialState +} from './exclusive-tracks/lineup/reducer' +export { tracksActions as exclusiveTracksPageLineupActions } from './exclusive-tracks/lineup/actions' +export { + default as exclusiveTracksPageReducer, + actions as exclusiveTracksPageActions +} from './exclusive-tracks/slice' +export * as exclusiveTracksPageSelectors from './exclusive-tracks/selectors' diff --git a/packages/common/src/store/reducers.ts b/packages/common/src/store/reducers.ts index 45220458f8e..559e351dbac 100644 --- a/packages/common/src/store/reducers.ts +++ b/packages/common/src/store/reducers.ts @@ -24,6 +24,7 @@ import { deactivateAccountReducer, DeactivateAccountState } from './pages/deactivate-account' +import exclusiveTracks from './pages/exclusive-tracks/slice' import feed from './pages/feed/reducer' import { FeedPageState } from './pages/feed/types' import historyPageReducer from './pages/history-page/reducer' @@ -195,6 +196,7 @@ export const reducers = (storage: Storage, history?: History) => ({ trendingUnderground, settings, remixes, + exclusiveTracks, premiumTracks }), search: searchReducer(storage), @@ -301,6 +303,7 @@ export type CommonState = { trendingPlaylists: ReturnType trendingUnderground: ReturnType remixes: ReturnType + exclusiveTracks: ReturnType premiumTracks: ReturnType } search: SearchState diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index fdc480126be..e73ca7c9fe8 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -72,6 +72,7 @@ export const SALES_PAGE = '/payments/sales' export const WITHDRAWALS_PAGE = '/payments/withdrawals' export const TRANSACTION_HISTORY_PAGE = '/wallet/transaction-history' export const COIN_DETAIL_PAGE = '/coins/:ticker' +export const COIN_EXCLUSIVE_TRACKS_PAGE = '/coins/:ticker/exclusive-tracks' export const EDIT_COIN_DETAILS_PAGE = '/coins/:ticker/edit' export const WALLET_PAGE = '/wallet' export const WALLET_GUIDE_PAGE = '/wallet/guide' @@ -154,6 +155,8 @@ export const FOLLOWING_USERS_ROUTE = '/following' export const FOLLOWERS_USERS_ROUTE = '/followers' export const LEADERBOARD_USERS_ROUTE = '/leaderboard' export const COIN_DETAIL_MOBILE_WEB_ROUTE = '/coins/:ticker/details' +export const COIN_EXCLUSIVE_TRACKS_MOBILE_ROUTE = + '/coins/:ticker/exclusive-tracks/mobile' export const SUPPORTING_USERS_ROUTE = '/supporting' export const TOP_SUPPORTERS_USERS_ROUTE = '/top-supporters' export const ACCOUNT_SETTINGS_PAGE = '/settings/account' diff --git a/packages/mobile/package.json b/packages/mobile/package.json index cb4c529e77a..c7c959def8a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -127,6 +127,7 @@ "react-native-fs": "2.20.0", "react-native-gesture-handler": "2.14.0", "react-native-get-random-values": "1.10.0", + "react-native-gifted-charts": "1.4.46", "react-native-google-cast": "4.6.2", "react-native-haptic-feedback": "2.2.0", "react-native-image-crop-picker": "0.42.0", diff --git a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx index 6151908e3c6..2ba074814dd 100644 --- a/packages/mobile/src/components/navigation-container/NavigationContainer.tsx +++ b/packages/mobile/src/components/navigation-container/NavigationContainer.tsx @@ -186,6 +186,9 @@ const NavigationContainer = (props: NavigationContainerProps) => { }, CoinDetailsScreen: { path: 'coins/:ticker' + }, + ExclusiveTracksScreen: { + path: 'coins/:ticker/exclusive-tracks' } } }, diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx index 561cc8bc18b..d921f95bf7c 100644 --- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx @@ -32,7 +32,8 @@ import { ChatScreen } from 'app/screens/chat-screen/ChatScreen' import { ChatUserListScreen } from 'app/screens/chat-screen/ChatUserListScreen' import { CoinDetailsScreen, - EditCoinDetailsScreen + EditCoinDetailsScreen, + ExclusiveTracksScreen } from 'app/screens/coin-details-screen' import { CollectionScreen } from 'app/screens/collection-screen/CollectionScreen' import { EditProfileScreen } from 'app/screens/edit-profile-screen' @@ -124,6 +125,7 @@ export type AppTabScreenParamList = { wallet: undefined CoinDetailsScreen: { ticker: string } EditCoinDetailsScreen: { ticker: string } + ExclusiveTracksScreen: { ticker: string } Upload: { initialMetadata?: Partial } @@ -240,6 +242,10 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { name='EditCoinDetailsScreen' component={EditCoinDetailsScreen} /> + { const { ticker } = useRoute().params as { ticker: string } @@ -49,6 +50,7 @@ export const CoinDetailsScreen = () => { + diff --git a/packages/mobile/src/screens/coin-details-screen/ExclusiveTracksScreen.tsx b/packages/mobile/src/screens/coin-details-screen/ExclusiveTracksScreen.tsx new file mode 100644 index 00000000000..79f87546f26 --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/ExclusiveTracksScreen.tsx @@ -0,0 +1,79 @@ +import { useMemo } from 'react' + +import { useArtistCoinByTicker } from '@audius/common/api' +import { useProxySelector } from '@audius/common/hooks' +import { + exclusiveTracksPageLineupActions as exclusiveTracksActions, + exclusiveTracksPageSelectors +} from '@audius/common/store' +import { useRoute } from '@react-navigation/native' + +import { Flex, Text } from '@audius/harmony-native' +import { Screen, ScreenContent } from 'app/components/core' +import { Lineup } from 'app/components/lineup' + +const { getLineup } = exclusiveTracksPageSelectors + +const messages = { + emptyTitle: 'No exclusive tracks yet', + emptyDescription: 'Check back later for exclusive content!' +} + +const EmptyExclusiveTracks = () => { + return ( + + + {messages.emptyTitle} + + + {messages.emptyDescription} + + + ) +} + +export const ExclusiveTracksScreen = () => { + const { ticker } = useRoute().params as { ticker: string } + const { data: coin } = useArtistCoinByTicker({ ticker }) + const ownerId = coin?.ownerId + const coinName = coin?.name ?? ticker + + const title = coinName ? `${coinName} Exclusive Tracks` : 'Exclusive Tracks' + + const lineup = useProxySelector((state) => getLineup(state), []) + + const fetchPayload = useMemo(() => ({ userId: ownerId }), [ownerId]) + + if (!ownerId) { + return ( + + + + + + ) + } + + return ( + + + } + showsVerticalScrollIndicator={false} + /> + + + ) +} diff --git a/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksCard.tsx b/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksCard.tsx new file mode 100644 index 00000000000..5ba71772f39 --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksCard.tsx @@ -0,0 +1,106 @@ +import { useCallback } from 'react' + +import { + useArtistCoin, + useExclusiveTracks, + useExclusiveTracksCount +} from '@audius/common/api' +import { exclusiveTracksPageLineupActions as exclusiveTracksActions } from '@audius/common/store' + +import { Button, Flex, Text } from '@audius/harmony-native' +import { TanQueryLineup } from 'app/components/lineup/TanQueryLineup' +import { useNavigation } from 'app/hooks/useNavigation' + +const messages = { + exclusiveTracks: 'Exclusive Tracks', + viewAll: 'View All' +} + +const MAX_PREVIEW_TRACKS = 3 + +const itemStyles = { + paddingHorizontal: 0 +} + +type ExclusiveTracksSectionProps = { + mint: string +} + +export const ExclusiveTracksSection = ({ + mint +}: ExclusiveTracksSectionProps) => { + const navigation = useNavigation() + const { data: coin } = useArtistCoin(mint) + const ownerId = coin?.ownerId + const coinName = coin?.name ?? coin?.ticker + + const { data, lineup, pageSize, isFetching, loadNextPage, isPending } = + useExclusiveTracks({ + userId: ownerId, + pageSize: MAX_PREVIEW_TRACKS + }) + + const { data: totalCount = 0 } = useExclusiveTracksCount({ + userId: ownerId + }) + + const handleViewAll = useCallback(() => { + if (coin?.ticker) { + navigation.navigate('ExclusiveTracksScreen', { ticker: coin.ticker }) + } + }, [coin?.ticker, navigation]) + + const shouldShowCard = totalCount > 0 && ownerId + + if (!shouldShowCard) return null + + const title = coinName + ? `${coinName} ${messages.exclusiveTracks}` + : messages.exclusiveTracks + + return ( + + + + + {title} + + {totalCount > 0 ? ( + + ({totalCount}) + + ) : null} + + + + + + ) +} diff --git a/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksSection.tsx b/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksSection.tsx new file mode 100644 index 00000000000..0f57de69492 --- /dev/null +++ b/packages/mobile/src/screens/coin-details-screen/components/ExclusiveTracksSection.tsx @@ -0,0 +1,99 @@ +import { useCallback } from 'react' + +import { + useArtistCoin, + useExclusiveTracks, + useExclusiveTracksCount +} from '@audius/common/api' +import { exclusiveTracksPageLineupActions as exclusiveTracksActions } from '@audius/common/store' + +import { Flex, PlainButton, Text } from '@audius/harmony-native' +import { TanQueryLineup } from 'app/components/lineup/TanQueryLineup' +import { useNavigation } from 'app/hooks/useNavigation' + +const messages = { + exclusiveTracks: 'Exclusive Tracks', + viewAll: 'View All' +} + +const MAX_PREVIEW_TRACKS = 3 + +const itemStyles = { + paddingHorizontal: 0 +} + +type ExclusiveTracksSectionProps = { + mint: string +} + +export const ExclusiveTracksSection = ({ + mint +}: ExclusiveTracksSectionProps) => { + const navigation = useNavigation() + const { data: coin } = useArtistCoin(mint) + const ownerId = coin?.ownerId + const coinName = coin?.name ?? coin?.ticker + + const { data, lineup, pageSize, isFetching, loadNextPage, isPending } = + useExclusiveTracks({ + userId: ownerId, + pageSize: MAX_PREVIEW_TRACKS + }) + + const { data: totalCount = 0 } = useExclusiveTracksCount({ + userId: ownerId + }) + + const handleViewAll = useCallback(() => { + if (coin?.ticker) { + navigation.navigate('ExclusiveTracksScreen', { ticker: coin.ticker }) + } + }, [coin?.ticker, navigation]) + + const shouldShowCard = totalCount > 0 && ownerId + + if (!shouldShowCard) return null + + const title = coinName + ? `${coinName} ${messages.exclusiveTracks}` + : messages.exclusiveTracks + + return ( + + + + + {title}{' '} + {totalCount > 0 ? ( + ({totalCount}) + ) : null} + + + + {messages.viewAll} + + + + + ) +} diff --git a/packages/mobile/src/screens/coin-details-screen/index.ts b/packages/mobile/src/screens/coin-details-screen/index.ts index 1b88375a13a..58fd1c13202 100644 --- a/packages/mobile/src/screens/coin-details-screen/index.ts +++ b/packages/mobile/src/screens/coin-details-screen/index.ts @@ -1,2 +1,3 @@ export { CoinDetailsScreen } from './CoinDetailsScreen' export { EditCoinDetailsScreen } from './EditCoinDetailsScreen' +export { ExclusiveTracksScreen } from './ExclusiveTracksScreen' diff --git a/packages/mobile/src/store/sagas.ts b/packages/mobile/src/store/sagas.ts index 8280eb8f980..5dc9d0eaed0 100644 --- a/packages/mobile/src/store/sagas.ts +++ b/packages/mobile/src/store/sagas.ts @@ -28,6 +28,7 @@ import changePasswordSagas from 'common/store/change-password/sagas' import rewardsPageSagas from 'common/store/pages/audio-rewards/sagas' import collectionPageSagas from 'common/store/pages/collection/sagas' import deactivateAccountSagas from 'common/store/pages/deactivate-account/sagas' +import exclusiveTracksSagas from 'common/store/pages/exclusive-tracks/sagas' import feedPageSagas from 'common/store/pages/feed/sagas' import historySagas from 'common/store/pages/history/sagas' import librarySagas from 'common/store/pages/library/sagas' @@ -126,6 +127,7 @@ export default function* rootSaga() { ...rewardsPageSagas(), ...settingsSagas(), ...premiumTracksSagas(), + ...exclusiveTracksSagas(), ...searchTracksLineupSagas(), // Cast diff --git a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES index 52bc2a24f53..86df177821d 100644 --- a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES @@ -22,6 +22,8 @@ models/AlbumBacklink.ts models/AlbumsResponse.ts models/AuthorizedApp.ts models/AuthorizedApps.ts +models/BalanceHistoryDataPoint.ts +models/BalanceHistoryResponse.ts models/BestSellingItem.ts models/BestSellingResponse.ts models/BlobInfo.ts @@ -42,6 +44,8 @@ models/CoinMembersResponse.ts models/CoinResponse.ts models/CoinRewardPool.ts models/CoinsResponse.ts +models/CoinsVolumeLeadersResponse.ts +models/CoinsVolumeLeadersResponseDataInner.ts models/Collectibles.ts models/CollectiblesResponse.ts models/CollectionActivity.ts diff --git a/packages/sdk/src/sdk/api/generated/default/apis/CoinsApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/CoinsApi.ts index 58734548aad..8c395f7ef4f 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/CoinsApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/CoinsApi.ts @@ -21,6 +21,7 @@ import type { CoinMembersResponse, CoinResponse, CoinsResponse, + CoinsVolumeLeadersResponse, CreateCoinRequest, CreateCoinResponse, UpdateCoinRequest, @@ -37,6 +38,8 @@ import { CoinResponseToJSON, CoinsResponseFromJSON, CoinsResponseToJSON, + CoinsVolumeLeadersResponseFromJSON, + CoinsVolumeLeadersResponseToJSON, CreateCoinRequestFromJSON, CreateCoinRequestToJSON, CreateCoinResponseFromJSON, @@ -86,6 +89,13 @@ export interface GetCoinsRequest { sortDirection?: GetCoinsSortDirectionEnum; } +export interface GetVolumeLeadersRequest { + from?: string; + to?: string; + offset?: number; + limit?: number; +} + export interface UpdateCoinOperationRequest { mint: string; userId: string; @@ -365,6 +375,49 @@ export class CoinsApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets top coin<>AUDIO trading addresses by volume + */ + async getVolumeLeadersRaw(params: GetVolumeLeadersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (params.from !== undefined) { + queryParameters['from'] = params.from; + } + + if (params.to !== undefined) { + queryParameters['to'] = params.to; + } + + if (params.offset !== undefined) { + queryParameters['offset'] = params.offset; + } + + if (params.limit !== undefined) { + queryParameters['limit'] = params.limit; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/coins/volume-leaders`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => CoinsVolumeLeadersResponseFromJSON(jsonValue)); + } + + /** + * Gets top coin<>AUDIO trading addresses by volume + */ + async getVolumeLeaders(params: GetVolumeLeadersRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getVolumeLeadersRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Updates information about a specific coin by its mint address diff --git a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts index c3f39c2632f..ceb61648ac7 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts @@ -18,6 +18,7 @@ import * as runtime from '../runtime'; import type { AlbumsResponse, AuthorizedApps, + BalanceHistoryResponse, CollectiblesResponse, ConnectedWalletsResponse, DeveloperApps, @@ -56,6 +57,8 @@ import { AlbumsResponseToJSON, AuthorizedAppsFromJSON, AuthorizedAppsToJSON, + BalanceHistoryResponseFromJSON, + BalanceHistoryResponseToJSON, CollectiblesResponseFromJSON, CollectiblesResponseToJSON, ConnectedWalletsResponseFromJSON, @@ -311,6 +314,7 @@ export interface GetTracksByUserRequest { sortMethod?: GetTracksByUserSortMethodEnum; sortDirection?: GetTracksByUserSortDirectionEnum; filterTracks?: GetTracksByUserFilterTracksEnum; + gateCondition?: Array; encodedDataMessage?: string; encodedDataSignature?: string; } @@ -319,6 +323,16 @@ export interface GetUserRequest { id: string; } +export interface GetUserBalanceHistoryRequest { + id: string; + startTime?: Date; + endTime?: Date; + granularity?: GetUserBalanceHistoryGranularityEnum; + userId?: string; + encodedDataMessage?: string; + encodedDataSignature?: string; +} + export interface GetUserByHandleRequest { handle: string; userId?: string; @@ -1544,6 +1558,10 @@ export class UsersApi extends runtime.BaseAPI { queryParameters['filter_tracks'] = params.filterTracks; } + if (params.gateCondition) { + queryParameters['gate_condition'] = params.gateCondition; + } + const headerParameters: runtime.HTTPHeaders = {}; if (params.encodedDataMessage !== undefined && params.encodedDataMessage !== null) { @@ -1603,6 +1621,61 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Get the user\'s historical portfolio balance data + */ + async getUserBalanceHistoryRaw(params: GetUserBalanceHistoryRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.id === null || params.id === undefined) { + throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getUserBalanceHistory.'); + } + + const queryParameters: any = {}; + + if (params.startTime !== undefined) { + queryParameters['start_time'] = (params.startTime as any).toISOString(); + } + + if (params.endTime !== undefined) { + queryParameters['end_time'] = (params.endTime as any).toISOString(); + } + + if (params.granularity !== undefined) { + queryParameters['granularity'] = params.granularity; + } + + if (params.userId !== undefined) { + queryParameters['user_id'] = params.userId; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (params.encodedDataMessage !== undefined && params.encodedDataMessage !== null) { + headerParameters['Encoded-Data-Message'] = String(params.encodedDataMessage); + } + + if (params.encodedDataSignature !== undefined && params.encodedDataSignature !== null) { + headerParameters['Encoded-Data-Signature'] = String(params.encodedDataSignature); + } + + const response = await this.request({ + path: `/users/{id}/balance/history`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => BalanceHistoryResponseFromJSON(jsonValue)); + } + + /** + * Get the user\'s historical portfolio balance data + */ + async getUserBalanceHistory(params: GetUserBalanceHistoryRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getUserBalanceHistoryRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets a single user by their handle @@ -2275,6 +2348,26 @@ export const GetTracksByUserFilterTracksEnum = { Unlisted: 'unlisted' } as const; export type GetTracksByUserFilterTracksEnum = typeof GetTracksByUserFilterTracksEnum[keyof typeof GetTracksByUserFilterTracksEnum]; +/** + * @export + */ +export const GetTracksByUserGateConditionEnum = { + Ungated: 'ungated', + UsdcPurchase: 'usdc_purchase', + Follow: 'follow', + Tip: 'tip', + Nft: 'nft', + Token: 'token' +} as const; +export type GetTracksByUserGateConditionEnum = typeof GetTracksByUserGateConditionEnum[keyof typeof GetTracksByUserGateConditionEnum]; +/** + * @export + */ +export const GetUserBalanceHistoryGranularityEnum = { + Hourly: 'hourly', + Daily: 'daily' +} as const; +export type GetUserBalanceHistoryGranularityEnum = typeof GetUserBalanceHistoryGranularityEnum[keyof typeof GetUserBalanceHistoryGranularityEnum]; /** * @export */ diff --git a/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryDataPoint.ts b/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryDataPoint.ts new file mode 100644 index 00000000000..3c8d42e5096 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryDataPoint.ts @@ -0,0 +1,76 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface BalanceHistoryDataPoint + */ +export interface BalanceHistoryDataPoint { + /** + * Unix timestamp in seconds + * @type {number} + * @memberof BalanceHistoryDataPoint + */ + timestamp: number; + /** + * Total portfolio balance in USD at this timestamp + * @type {number} + * @memberof BalanceHistoryDataPoint + */ + balanceUsd: number; +} + +/** + * Check if a given object implements the BalanceHistoryDataPoint interface. + */ +export function instanceOfBalanceHistoryDataPoint(value: object): value is BalanceHistoryDataPoint { + let isInstance = true; + isInstance = isInstance && "timestamp" in value && value["timestamp"] !== undefined; + isInstance = isInstance && "balanceUsd" in value && value["balanceUsd"] !== undefined; + + return isInstance; +} + +export function BalanceHistoryDataPointFromJSON(json: any): BalanceHistoryDataPoint { + return BalanceHistoryDataPointFromJSONTyped(json, false); +} + +export function BalanceHistoryDataPointFromJSONTyped(json: any, ignoreDiscriminator: boolean): BalanceHistoryDataPoint { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'timestamp': json['timestamp'], + 'balanceUsd': json['balance_usd'], + }; +} + +export function BalanceHistoryDataPointToJSON(value?: BalanceHistoryDataPoint | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'timestamp': value.timestamp, + 'balance_usd': value.balanceUsd, + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryResponse.ts b/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryResponse.ts new file mode 100644 index 00000000000..8dae5feb0b0 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/BalanceHistoryResponse.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { BalanceHistoryDataPoint } from './BalanceHistoryDataPoint'; +import { + BalanceHistoryDataPointFromJSON, + BalanceHistoryDataPointFromJSONTyped, + BalanceHistoryDataPointToJSON, +} from './BalanceHistoryDataPoint'; + +/** + * + * @export + * @interface BalanceHistoryResponse + */ +export interface BalanceHistoryResponse { + /** + * + * @type {Array} + * @memberof BalanceHistoryResponse + */ + data: Array; +} + +/** + * Check if a given object implements the BalanceHistoryResponse interface. + */ +export function instanceOfBalanceHistoryResponse(value: object): value is BalanceHistoryResponse { + let isInstance = true; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function BalanceHistoryResponseFromJSON(json: any): BalanceHistoryResponse { + return BalanceHistoryResponseFromJSONTyped(json, false); +} + +export function BalanceHistoryResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): BalanceHistoryResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': ((json['data'] as Array).map(BalanceHistoryDataPointFromJSON)), + }; +} + +export function BalanceHistoryResponseToJSON(value?: BalanceHistoryResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': ((value.data as Array).map(BalanceHistoryDataPointToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponse.ts b/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponse.ts new file mode 100644 index 00000000000..b7faa999565 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponse.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { CoinsVolumeLeadersResponseDataInner } from './CoinsVolumeLeadersResponseDataInner'; +import { + CoinsVolumeLeadersResponseDataInnerFromJSON, + CoinsVolumeLeadersResponseDataInnerFromJSONTyped, + CoinsVolumeLeadersResponseDataInnerToJSON, +} from './CoinsVolumeLeadersResponseDataInner'; + +/** + * + * @export + * @interface CoinsVolumeLeadersResponse + */ +export interface CoinsVolumeLeadersResponse { + /** + * + * @type {Array} + * @memberof CoinsVolumeLeadersResponse + */ + data: Array; +} + +/** + * Check if a given object implements the CoinsVolumeLeadersResponse interface. + */ +export function instanceOfCoinsVolumeLeadersResponse(value: object): value is CoinsVolumeLeadersResponse { + let isInstance = true; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function CoinsVolumeLeadersResponseFromJSON(json: any): CoinsVolumeLeadersResponse { + return CoinsVolumeLeadersResponseFromJSONTyped(json, false); +} + +export function CoinsVolumeLeadersResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): CoinsVolumeLeadersResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': ((json['data'] as Array).map(CoinsVolumeLeadersResponseDataInnerFromJSON)), + }; +} + +export function CoinsVolumeLeadersResponseToJSON(value?: CoinsVolumeLeadersResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': ((value.data as Array).map(CoinsVolumeLeadersResponseDataInnerToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponseDataInner.ts b/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponseDataInner.ts new file mode 100644 index 00000000000..c891b434367 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/CoinsVolumeLeadersResponseDataInner.ts @@ -0,0 +1,91 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * Audius V1 API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { User } from './User'; +import { + UserFromJSON, + UserFromJSONTyped, + UserToJSON, +} from './User'; + +/** + * + * @export + * @interface CoinsVolumeLeadersResponseDataInner + */ +export interface CoinsVolumeLeadersResponseDataInner { + /** + * + * @type {string} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + address: string; + /** + * + * @type {number} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + volume: number; + /** + * + * @type {User} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + user?: User; +} + +/** + * Check if a given object implements the CoinsVolumeLeadersResponseDataInner interface. + */ +export function instanceOfCoinsVolumeLeadersResponseDataInner(value: object): value is CoinsVolumeLeadersResponseDataInner { + let isInstance = true; + isInstance = isInstance && "address" in value && value["address"] !== undefined; + isInstance = isInstance && "volume" in value && value["volume"] !== undefined; + + return isInstance; +} + +export function CoinsVolumeLeadersResponseDataInnerFromJSON(json: any): CoinsVolumeLeadersResponseDataInner { + return CoinsVolumeLeadersResponseDataInnerFromJSONTyped(json, false); +} + +export function CoinsVolumeLeadersResponseDataInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): CoinsVolumeLeadersResponseDataInner { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'address': json['address'], + 'volume': json['volume'], + 'user': !exists(json, 'user') ? undefined : UserFromJSON(json['user']), + }; +} + +export function CoinsVolumeLeadersResponseDataInnerToJSON(value?: CoinsVolumeLeadersResponseDataInner | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'address': value.address, + 'volume': value.volume, + 'user': UserToJSON(value.user), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/index.ts b/packages/sdk/src/sdk/api/generated/default/models/index.ts index abd10c72e08..0574beb3c70 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/index.ts @@ -7,6 +7,8 @@ export * from './AlbumBacklink'; export * from './AlbumsResponse'; export * from './AuthorizedApp'; export * from './AuthorizedApps'; +export * from './BalanceHistoryDataPoint'; +export * from './BalanceHistoryResponse'; export * from './BestSellingItem'; export * from './BestSellingResponse'; export * from './BlobInfo'; @@ -27,6 +29,8 @@ export * from './CoinMembersResponse'; export * from './CoinResponse'; export * from './CoinRewardPool'; export * from './CoinsResponse'; +export * from './CoinsVolumeLeadersResponse'; +export * from './CoinsVolumeLeadersResponseDataInner'; export * from './Collectibles'; export * from './CollectiblesResponse'; export * from './CollectionActivity'; diff --git a/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES b/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES index e701b16c077..c0dae2a5a20 100644 --- a/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES +++ b/packages/sdk/src/sdk/api/generated/full/.openapi-generator/FILES @@ -53,6 +53,8 @@ models/Coin.ts models/CoinMembersCountResponse.ts models/CoinResponse.ts models/CoinsResponse.ts +models/CoinsVolumeLeadersResponse.ts +models/CoinsVolumeLeadersResponseDataInner.ts models/CollectionActivityFull.ts models/CollectionActivityFullWithoutTracks.ts models/CollectionLibraryResponseFull.ts @@ -249,6 +251,7 @@ models/TrackLibraryResponseFull.ts models/TrackMilestoneNotificationActionData.ts models/TrackRepostsResponseFull.ts models/TrackSegment.ts +models/TracksCountResponse.ts models/TransactionDetails.ts models/TransactionHistoryCountResponse.ts models/TransactionHistoryResponse.ts diff --git a/packages/sdk/src/sdk/api/generated/full/apis/CoinsApi.ts b/packages/sdk/src/sdk/api/generated/full/apis/CoinsApi.ts index 40966a00344..186d8ef062d 100644 --- a/packages/sdk/src/sdk/api/generated/full/apis/CoinsApi.ts +++ b/packages/sdk/src/sdk/api/generated/full/apis/CoinsApi.ts @@ -19,6 +19,7 @@ import type { CoinMembersCountResponse, CoinResponse, CoinsResponse, + CoinsVolumeLeadersResponse, CreateCoinRequest, CreateCoinResponse, UpdateCoinRequest, @@ -31,6 +32,8 @@ import { CoinResponseToJSON, CoinsResponseFromJSON, CoinsResponseToJSON, + CoinsVolumeLeadersResponseFromJSON, + CoinsVolumeLeadersResponseToJSON, CreateCoinRequestFromJSON, CreateCoinRequestToJSON, CreateCoinResponseFromJSON, @@ -69,6 +72,13 @@ export interface GetCoinsRequest { sortDirection?: GetCoinsSortDirectionEnum; } +export interface GetVolumeLeadersRequest { + from?: string; + to?: string; + offset?: number; + limit?: number; +} + export interface UpdateCoinOperationRequest { mint: string; userId: string; @@ -274,6 +284,49 @@ export class CoinsApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets top coin<>AUDIO trading addresses by volume + */ + async getVolumeLeadersRaw(params: GetVolumeLeadersRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (params.from !== undefined) { + queryParameters['from'] = params.from; + } + + if (params.to !== undefined) { + queryParameters['to'] = params.to; + } + + if (params.offset !== undefined) { + queryParameters['offset'] = params.offset; + } + + if (params.limit !== undefined) { + queryParameters['limit'] = params.limit; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + const response = await this.request({ + path: `/coins/volume-leaders`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => CoinsVolumeLeadersResponseFromJSON(jsonValue)); + } + + /** + * Gets top coin<>AUDIO trading addresses by volume + */ + async getVolumeLeaders(params: GetVolumeLeadersRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getVolumeLeadersRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Updates information about a specific coin by its mint address diff --git a/packages/sdk/src/sdk/api/generated/full/apis/UsersApi.ts b/packages/sdk/src/sdk/api/generated/full/apis/UsersApi.ts index c933dc1a034..ddb5310379f 100644 --- a/packages/sdk/src/sdk/api/generated/full/apis/UsersApi.ts +++ b/packages/sdk/src/sdk/api/generated/full/apis/UsersApi.ts @@ -44,6 +44,7 @@ import type { TopGenreUsersResponseFull, TopUsersResponseFull, TrackLibraryResponseFull, + TracksCountResponse, TransactionHistoryCountResponse, TransactionHistoryResponse, UserAccountResponseFull, @@ -107,6 +108,8 @@ import { TopUsersResponseFullToJSON, TrackLibraryResponseFullFromJSON, TrackLibraryResponseFullToJSON, + TracksCountResponseFromJSON, + TracksCountResponseToJSON, TransactionHistoryCountResponseFromJSON, TransactionHistoryCountResponseToJSON, TransactionHistoryResponseFromJSON, @@ -386,6 +389,7 @@ export interface GetTracksByUserRequest { sortMethod?: GetTracksByUserSortMethodEnum; sortDirection?: GetTracksByUserSortDirectionEnum; filterTracks?: GetTracksByUserFilterTracksEnum; + gateCondition?: Array; encodedDataMessage?: string; encodedDataSignature?: string; } @@ -404,6 +408,15 @@ export interface GetTracksByUserHandleRequest { encodedDataSignature?: string; } +export interface GetTracksCountByUserRequest { + id: string; + userId?: string; + filterTracks?: GetTracksCountByUserFilterTracksEnum; + gateCondition?: Array; + encodedDataMessage?: string; + encodedDataSignature?: string; +} + export interface GetUSDCTransactionCountRequest { id: string; type?: Array; @@ -2100,6 +2113,10 @@ export class UsersApi extends runtime.BaseAPI { queryParameters['filter_tracks'] = params.filterTracks; } + if (params.gateCondition) { + queryParameters['gate_condition'] = params.gateCondition; + } + const headerParameters: runtime.HTTPHeaders = {}; if (params.encodedDataMessage !== undefined && params.encodedDataMessage !== null) { @@ -2199,6 +2216,57 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets the count of tracks created by a user + */ + async getTracksCountByUserRaw(params: GetTracksCountByUserRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + if (params.id === null || params.id === undefined) { + throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getTracksCountByUser.'); + } + + const queryParameters: any = {}; + + if (params.userId !== undefined) { + queryParameters['user_id'] = params.userId; + } + + if (params.filterTracks !== undefined) { + queryParameters['filter_tracks'] = params.filterTracks; + } + + if (params.gateCondition) { + queryParameters['gate_condition'] = params.gateCondition; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (params.encodedDataMessage !== undefined && params.encodedDataMessage !== null) { + headerParameters['Encoded-Data-Message'] = String(params.encodedDataMessage); + } + + if (params.encodedDataSignature !== undefined && params.encodedDataSignature !== null) { + headerParameters['Encoded-Data-Signature'] = String(params.encodedDataSignature); + } + + const response = await this.request({ + path: `/users/{id}/tracks/count`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => TracksCountResponseFromJSON(jsonValue)); + } + + /** + * Gets the count of tracks created by a user + */ + async getTracksCountByUser(params: GetTracksCountByUserRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getTracksCountByUserRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets the count of the user\'s $USDC transaction history within the App @@ -3026,6 +3094,18 @@ export const GetTracksByUserFilterTracksEnum = { Unlisted: 'unlisted' } as const; export type GetTracksByUserFilterTracksEnum = typeof GetTracksByUserFilterTracksEnum[keyof typeof GetTracksByUserFilterTracksEnum]; +/** + * @export + */ +export const GetTracksByUserGateConditionEnum = { + Ungated: 'ungated', + UsdcPurchase: 'usdc_purchase', + Follow: 'follow', + Tip: 'tip', + Nft: 'nft', + Token: 'token' +} as const; +export type GetTracksByUserGateConditionEnum = typeof GetTracksByUserGateConditionEnum[keyof typeof GetTracksByUserGateConditionEnum]; /** * @export */ @@ -3066,6 +3146,27 @@ export const GetTracksByUserHandleFilterTracksEnum = { Unlisted: 'unlisted' } as const; export type GetTracksByUserHandleFilterTracksEnum = typeof GetTracksByUserHandleFilterTracksEnum[keyof typeof GetTracksByUserHandleFilterTracksEnum]; +/** + * @export + */ +export const GetTracksCountByUserFilterTracksEnum = { + All: 'all', + Public: 'public', + Unlisted: 'unlisted' +} as const; +export type GetTracksCountByUserFilterTracksEnum = typeof GetTracksCountByUserFilterTracksEnum[keyof typeof GetTracksCountByUserFilterTracksEnum]; +/** + * @export + */ +export const GetTracksCountByUserGateConditionEnum = { + Ungated: 'ungated', + UsdcPurchase: 'usdc_purchase', + Follow: 'follow', + Tip: 'tip', + Nft: 'nft', + Token: 'token' +} as const; +export type GetTracksCountByUserGateConditionEnum = typeof GetTracksCountByUserGateConditionEnum[keyof typeof GetTracksCountByUserGateConditionEnum]; /** * @export */ diff --git a/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponse.ts b/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponse.ts new file mode 100644 index 00000000000..9b4e18f87be --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponse.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { CoinsVolumeLeadersResponseDataInner } from './CoinsVolumeLeadersResponseDataInner'; +import { + CoinsVolumeLeadersResponseDataInnerFromJSON, + CoinsVolumeLeadersResponseDataInnerFromJSONTyped, + CoinsVolumeLeadersResponseDataInnerToJSON, +} from './CoinsVolumeLeadersResponseDataInner'; + +/** + * + * @export + * @interface CoinsVolumeLeadersResponse + */ +export interface CoinsVolumeLeadersResponse { + /** + * + * @type {Array} + * @memberof CoinsVolumeLeadersResponse + */ + data: Array; +} + +/** + * Check if a given object implements the CoinsVolumeLeadersResponse interface. + */ +export function instanceOfCoinsVolumeLeadersResponse(value: object): value is CoinsVolumeLeadersResponse { + let isInstance = true; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function CoinsVolumeLeadersResponseFromJSON(json: any): CoinsVolumeLeadersResponse { + return CoinsVolumeLeadersResponseFromJSONTyped(json, false); +} + +export function CoinsVolumeLeadersResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): CoinsVolumeLeadersResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': ((json['data'] as Array).map(CoinsVolumeLeadersResponseDataInnerFromJSON)), + }; +} + +export function CoinsVolumeLeadersResponseToJSON(value?: CoinsVolumeLeadersResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': ((value.data as Array).map(CoinsVolumeLeadersResponseDataInnerToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponseDataInner.ts b/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponseDataInner.ts new file mode 100644 index 00000000000..3c916bed0b3 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/CoinsVolumeLeadersResponseDataInner.ts @@ -0,0 +1,91 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { UserFull } from './UserFull'; +import { + UserFullFromJSON, + UserFullFromJSONTyped, + UserFullToJSON, +} from './UserFull'; + +/** + * + * @export + * @interface CoinsVolumeLeadersResponseDataInner + */ +export interface CoinsVolumeLeadersResponseDataInner { + /** + * + * @type {string} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + address: string; + /** + * + * @type {number} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + volume: number; + /** + * + * @type {UserFull} + * @memberof CoinsVolumeLeadersResponseDataInner + */ + user?: UserFull; +} + +/** + * Check if a given object implements the CoinsVolumeLeadersResponseDataInner interface. + */ +export function instanceOfCoinsVolumeLeadersResponseDataInner(value: object): value is CoinsVolumeLeadersResponseDataInner { + let isInstance = true; + isInstance = isInstance && "address" in value && value["address"] !== undefined; + isInstance = isInstance && "volume" in value && value["volume"] !== undefined; + + return isInstance; +} + +export function CoinsVolumeLeadersResponseDataInnerFromJSON(json: any): CoinsVolumeLeadersResponseDataInner { + return CoinsVolumeLeadersResponseDataInnerFromJSONTyped(json, false); +} + +export function CoinsVolumeLeadersResponseDataInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): CoinsVolumeLeadersResponseDataInner { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'address': json['address'], + 'volume': json['volume'], + 'user': !exists(json, 'user') ? undefined : UserFullFromJSON(json['user']), + }; +} + +export function CoinsVolumeLeadersResponseDataInnerToJSON(value?: CoinsVolumeLeadersResponseDataInner | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'address': value.address, + 'volume': value.volume, + 'user': UserFullToJSON(value.user), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/TracksCountResponse.ts b/packages/sdk/src/sdk/api/generated/full/models/TracksCountResponse.ts new file mode 100644 index 00000000000..8821c926b22 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/full/models/TracksCountResponse.ts @@ -0,0 +1,67 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface TracksCountResponse + */ +export interface TracksCountResponse { + /** + * The total number of tracks matching the filter criteria + * @type {number} + * @memberof TracksCountResponse + */ + data: number; +} + +/** + * Check if a given object implements the TracksCountResponse interface. + */ +export function instanceOfTracksCountResponse(value: object): value is TracksCountResponse { + let isInstance = true; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function TracksCountResponseFromJSON(json: any): TracksCountResponse { + return TracksCountResponseFromJSONTyped(json, false); +} + +export function TracksCountResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): TracksCountResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': json['data'], + }; +} + +export function TracksCountResponseToJSON(value?: TracksCountResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': value.data, + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/full/models/index.ts b/packages/sdk/src/sdk/api/generated/full/models/index.ts index cdaec02b47c..88b22a2ad51 100644 --- a/packages/sdk/src/sdk/api/generated/full/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/full/models/index.ts @@ -39,6 +39,8 @@ export * from './Coin'; export * from './CoinMembersCountResponse'; export * from './CoinResponse'; export * from './CoinsResponse'; +export * from './CoinsVolumeLeadersResponse'; +export * from './CoinsVolumeLeadersResponseDataInner'; export * from './CollectionActivityFull'; export * from './CollectionActivityFullWithoutTracks'; export * from './CollectionLibraryResponseFull'; @@ -235,6 +237,7 @@ export * from './TrackLibraryResponseFull'; export * from './TrackMilestoneNotificationActionData'; export * from './TrackRepostsResponseFull'; export * from './TrackSegment'; +export * from './TracksCountResponse'; export * from './TransactionDetails'; export * from './TransactionHistoryCountResponse'; export * from './TransactionHistoryResponse'; diff --git a/packages/web/src/app/web-player/WebPlayer.jsx b/packages/web/src/app/web-player/WebPlayer.jsx index ae212426adc..71fb44e8a7e 100644 --- a/packages/web/src/app/web-player/WebPlayer.jsx +++ b/packages/web/src/app/web-player/WebPlayer.jsx @@ -56,7 +56,9 @@ import { MobileArtistCoinsSortPage } from 'pages/artist-coins-sort-page/MobileAr import { AudioPage } from 'pages/audio-page/AudioPage' import { ChatPageProvider } from 'pages/chat-page/ChatPageProvider' import { CoinDetailPage } from 'pages/coin-detail-page/CoinDetailPage' +import { ExclusiveTracksPage } from 'pages/coin-detail-page/components/ExclusiveTracksPage' import { ArtistCoinDetailsPage } from 'pages/coin-detail-page/components/mobile/ArtistCoinDetailsPage' +import { ExclusiveTracksPage as MobileExclusiveTracksPage } from 'pages/coin-detail-page/components/mobile/ExclusiveTracksPage' import CollectionPage from 'pages/collection-page/CollectionPage' import CommentHistoryPage from 'pages/comment-history/CommentHistoryPage' import { DashboardPage } from 'pages/dashboard-page/DashboardPage' @@ -166,6 +168,8 @@ const { PROFILE_PAGE_PLAYLISTS, PROFILE_PAGE_REPOSTS, TRENDING_UNDERGROUND_PAGE, + COIN_EXCLUSIVE_TRACKS_PAGE, + COIN_EXCLUSIVE_TRACKS_MOBILE_ROUTE, CHECK_PAGE, TRENDING_PLAYLISTS_PAGE_LEGACY, DEACTIVATE_PAGE, @@ -741,6 +745,20 @@ const WebPlayer = (props) => { return }} /> + + { + const ticker = props.match.params.ticker + return + }} + /> { + constructor() { + super( + exclusiveTracksPageLineupActions.prefix, + exclusiveTracksPageLineupActions, + getLineup, + getExclusiveTracks, + undefined, + true, + undefined + ) + } +} + +export default function sagas() { + return new ExclusiveTracksSagas().getSagas() +} diff --git a/packages/web/src/common/store/pages/exclusive-tracks/sagas.ts b/packages/web/src/common/store/pages/exclusive-tracks/sagas.ts new file mode 100644 index 00000000000..0371430a963 --- /dev/null +++ b/packages/web/src/common/store/pages/exclusive-tracks/sagas.ts @@ -0,0 +1,5 @@ +import exclusiveTracksLineupSagas from './lineups/tracks/sagas' + +export default function sagas() { + return [...exclusiveTracksLineupSagas()] +} diff --git a/packages/web/src/pages/coin-detail-page/CoinDetailContent.tsx b/packages/web/src/pages/coin-detail-page/CoinDetailContent.tsx index 8970a89d6ca..d6d4e92105a 100644 --- a/packages/web/src/pages/coin-detail-page/CoinDetailContent.tsx +++ b/packages/web/src/pages/coin-detail-page/CoinDetailContent.tsx @@ -4,6 +4,7 @@ import { BalanceSection } from './components/BalanceSection' import { CoinInfoSection } from './components/CoinInfoSection' import { CoinInsights } from './components/CoinInsights' import { CoinLeaderboardCard } from './components/CoinLeaderboardCard' +import { ExclusiveTracksSection } from './components/ExclusiveTracksSection' const LEFT_SECTION_WIDTH = '704px' const RIGHT_SECTION_WIDTH = '360px' @@ -66,6 +67,7 @@ export const CoinDetailContent = ({ mint }: CoinDetailContentProps) => { + ) diff --git a/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksPage.tsx b/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksPage.tsx new file mode 100644 index 00000000000..5578852f514 --- /dev/null +++ b/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksPage.tsx @@ -0,0 +1,76 @@ +import { useArtistCoinByTicker, useExclusiveTracks } from '@audius/common/api' +import { exclusiveTracksPageLineupActions } from '@audius/common/store' +import { Flex } from '@audius/harmony' +import { useParams } from 'react-router-dom' + +import { Header } from 'components/header/desktop/Header' +import { TanQueryLineup } from 'components/lineup/TanQueryLineup' +import Page from 'components/page/Page' + +const PAGE_SIZE = 100 + +export const ExclusiveTracksPage = () => { + const { ticker } = useParams<{ ticker: string }>() + + const { data: coin } = useArtistCoinByTicker({ ticker }) + const ownerId = coin?.ownerId + const coinName = coin?.name ?? ticker + + const title = coinName ? `${coinName} Exclusive Tracks` : 'Exclusive Tracks' + + const { + data, + isFetching, + isPending, + isError, + hasNextPage, + play, + pause, + loadNextPage, + isPlaying, + lineup + } = useExclusiveTracks({ + userId: ownerId, + pageSize: PAGE_SIZE + }) + + const header =
+ + return ( + + + + + + + + ) +} diff --git a/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksSection.tsx b/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksSection.tsx new file mode 100644 index 00000000000..ac4d6c343c4 --- /dev/null +++ b/packages/web/src/pages/coin-detail-page/components/ExclusiveTracksSection.tsx @@ -0,0 +1,103 @@ +import { + useExclusiveTracks, + useExclusiveTracksCount, + useArtistCoin +} from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Flex, PlainButton, Skeleton, Text } from '@audius/harmony' +import { useNavigate } from 'react-router-dom-v5-compat' + +import { TrackTile } from 'components/track/desktop/TrackTile' +import { TrackTileSize } from 'components/track/types' +import { useIsMobile } from 'hooks/useIsMobile' + +const messages = { + exclusiveTracks: 'Exclusive Tracks', + viewAll: 'View All' +} + +const MAX_PREVIEW_TRACKS = 3 + +type ExclusiveTracksSectionProps = { + mint: string +} + +export const ExclusiveTracksSection = ({ + mint +}: ExclusiveTracksSectionProps) => { + const navigate = useNavigate() + const isMobile = useIsMobile() + const { data: coin } = useArtistCoin(mint) + const ownerId = coin?.ownerId + + // Fetch exclusive tracks (token-gated) for the coin owner + const { data: tracks, lineup } = useExclusiveTracks({ + userId: ownerId, + pageSize: MAX_PREVIEW_TRACKS + }) + + const { data: totalCount = 0 } = useExclusiveTracksCount({ + userId: ownerId + }) + + const handleViewAll = () => { + if (coin?.ticker) { + if (isMobile) { + navigate(`/coins/${coin.ticker}/exclusive-tracks/mobile`) + } else { + navigate(route.coinPage(coin.ticker) + '/exclusive-tracks') + } + } + } + + const shouldShowSection = totalCount > 0 && ownerId + + if (!shouldShowSection) return null + + const isLoading = lineup.isMetadataLoading + + return ( + + + + + {messages.exclusiveTracks} + + + ({totalCount}) + + + + {messages.viewAll} + + + + {isLoading ? ( + + {Array.from({ length: MAX_PREVIEW_TRACKS }).map((_, index) => ( + + ))} + + ) : ( + + {tracks?.map((track, index) => ( + {}} + isLoading={false} + hasLoaded={() => {}} + isTrending={false} + isFeed={false} + /> + ))} + + )} + + ) +} diff --git a/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.module.css b/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.module.css new file mode 100644 index 00000000000..a85e8ed07d1 --- /dev/null +++ b/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.module.css @@ -0,0 +1,15 @@ +.container { + padding: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: center; + gap: var(--unit-2); + width: 100%; +} + +.tracksContainer { + padding: var(--unit-3); +} diff --git a/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.tsx b/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.tsx new file mode 100644 index 00000000000..a179f583b32 --- /dev/null +++ b/packages/web/src/pages/coin-detail-page/components/mobile/ExclusiveTracksPage.tsx @@ -0,0 +1,79 @@ +import { useEffect, useContext } from 'react' + +import { useArtistCoinByTicker, useExclusiveTracks } from '@audius/common/api' +import { exclusiveTracksPageLineupActions } from '@audius/common/store' + +import Header from 'components/header/mobile/Header' +import { HeaderContext } from 'components/header/mobile/HeaderContextProvider' +import { TanQueryLineup } from 'components/lineup/TanQueryLineup' +import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' +import { useSubPageHeader } from 'components/nav/mobile/NavContext' + +import styles from './ExclusiveTracksPage.module.css' + +const PAGE_SIZE = 100 + +type ExclusiveTracksPageProps = { + ticker: string +} + +export const ExclusiveTracksPage = ({ ticker }: ExclusiveTracksPageProps) => { + useSubPageHeader() + + const { data: coin } = useArtistCoinByTicker({ ticker }) + const ownerId = coin?.ownerId + const coinName = coin?.name ?? ticker + + const title = coinName ? `${coinName} Exclusive Tracks` : 'Exclusive Tracks' + + const { + data, + isFetching, + isPending, + isError, + hasNextPage, + play, + pause, + loadNextPage, + isPlaying, + lineup, + pageSize + } = useExclusiveTracks({ + userId: ownerId, + pageSize: PAGE_SIZE + }) + + const { setHeader } = useContext(HeaderContext) + useEffect(() => { + setHeader( + <> +
+ + ) + }, [setHeader, title]) + + return ( + +
+ +
+
+ ) +} diff --git a/packages/web/src/store/sagas.ts b/packages/web/src/store/sagas.ts index 8bdd4ae99b7..b495d360d6d 100644 --- a/packages/web/src/store/sagas.ts +++ b/packages/web/src/store/sagas.ts @@ -31,6 +31,7 @@ import changePasswordSagas from 'common/store/change-password/sagas' import rewardsPageSagas from 'common/store/pages/audio-rewards/sagas' import collectionSagas from 'common/store/pages/collection/sagas' import deactivateAccountSagas from 'common/store/pages/deactivate-account/sagas' +import exclusiveTracksSagas from 'common/store/pages/exclusive-tracks/sagas' import feedPageSagas from 'common/store/pages/feed/sagas' import historySagas from 'common/store/pages/history/sagas' import librarySagas from 'common/store/pages/library/sagas' @@ -111,7 +112,9 @@ export default function* rootSaga() { trendingUndergroundSagas(), uploadSagas(), premiumTracksSagas(), + exclusiveTracksSagas(), searchTracksLineupSagas(), + remixesSagas(), modalsSagas(),