diff --git a/packages/common/src/api/tan-query/events/index.ts b/packages/common/src/api/tan-query/events/index.ts index 6fcc9b65882..57ceaba5e72 100644 --- a/packages/common/src/api/tan-query/events/index.ts +++ b/packages/common/src/api/tan-query/events/index.ts @@ -1,5 +1,6 @@ // Queries export * from './useAllEvents' +export * from './useAllRemixContests' export * from './useEvent' export * from './useEvents' export * from './useEventsByEntityId' diff --git a/packages/common/src/api/tan-query/events/useAllRemixContests.ts b/packages/common/src/api/tan-query/events/useAllRemixContests.ts new file mode 100644 index 00000000000..b4d73958b59 --- /dev/null +++ b/packages/common/src/api/tan-query/events/useAllRemixContests.ts @@ -0,0 +1,90 @@ +import { GetRemixContestsStatusEnum, Event as SDKEvent } from '@audius/sdk' +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' + +import { eventMetadataFromSDK } from '~/adapters/event' +import { useQueryContext } from '~/api/tan-query/utils' +import { ID } from '~/models' +import { removeNullable } from '~/utils' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, QueryOptions } from '../types' + +import { getEventQueryKey } from './utils' + +const DEFAULT_PAGE_SIZE = 25 + +export type RemixContestStatus = GetRemixContestsStatusEnum + +type UseAllRemixContestsArgs = { + pageSize?: number + /** + * Filter by contest status. Defaults to `'all'` (the backend's default), + * which returns active contests first (ordered by soonest-ending end_date) + * followed by ended contests (most-recently-ended first). + */ + status?: RemixContestStatus +} + +export const getAllRemixContestsQueryKey = ({ + pageSize = DEFAULT_PAGE_SIZE, + status = GetRemixContestsStatusEnum.All +}: UseAllRemixContestsArgs = {}) => + [QUERY_KEYS.remixContestsList, { pageSize, status }] as unknown as QueryKey< + ID[] + > + +/** + * Hook to fetch all remix contest events with infinite query support. + * Calls the dedicated discovery endpoint `GET /v1/events/remix-contests` + * (SDK: `events.getRemixContests`), which returns events ordered with + * currently-active contests first (by soonest-ending end_date) followed by + * ended contests. + * + * Each page is mapped to the remix contest's parent track ID + * (`event.entityId`) so consumers like `ContestCard` can receive a + * `trackId` prop and resolve the event internally via `useRemixContest`. + */ +export const useAllRemixContests = ( + { + pageSize = DEFAULT_PAGE_SIZE, + status = GetRemixContestsStatusEnum.All + }: UseAllRemixContestsArgs = {}, + options?: QueryOptions +) => { + const { audiusSdk } = useQueryContext() + const queryClient = useQueryClient() + + return useInfiniteQuery({ + queryKey: getAllRemixContestsQueryKey({ pageSize, status }), + initialPageParam: 0, + getNextPageParam: (lastPage: ID[], allPages) => { + if (lastPage.length < pageSize) return undefined + return allPages.length * pageSize + }, + queryFn: async ({ pageParam }) => { + const sdk = await audiusSdk() + const { data } = await sdk.events.getRemixContests({ + limit: pageSize, + offset: pageParam, + status + }) + + if (!data) return [] + + return data + .map((sdkEvent: SDKEvent) => { + const event = eventMetadataFromSDK(sdkEvent) + if (!event) return null + // Prime the per-event cache so useEvent / useRemixContest hits + // immediately downstream. + queryClient.setQueryData(getEventQueryKey(event.eventId), event) + // Return the contest's parent track ID (event.entityId). The card + // takes a trackId and resolves the event via useRemixContest. + return event.entityId ?? null + }) + .filter(removeNullable) + }, + select: (data) => data.pages.flat(), + ...options + }) +} diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index f778a39a2c4..4e2721ad9d1 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -89,6 +89,7 @@ export const QUERY_KEYS = { userCoinBalance: 'userCoinBalance', events: 'events', eventsByEntityId: 'eventsByEntityId', + remixContestsList: 'remixContestsList', walletOwner: 'walletOwner', tokenPrice: 'tokenPrice', usdcBalance: 'usdcBalance', diff --git a/packages/common/src/messages/explore.ts b/packages/common/src/messages/explore.ts index b1497b0fec2..a51c720a327 100644 --- a/packages/common/src/messages/explore.ts +++ b/packages/common/src/messages/explore.ts @@ -6,6 +6,7 @@ export const exploreMessages = { featuredPlaylists: 'Community Playlists', fanClubs: 'Fan Clubs', featuredRemixContests: 'Featured Remix Contests', + contests: 'Contests', forYou: 'For You', recentlyListedForSale: 'Recently Listed for Sale', bestSelling: 'Best Selling', diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index c65b883e867..ef3181ff0b8 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -15,7 +15,8 @@ export enum FeatureFlags { REACT_QUERY_SYNC = 'react_query_sync', COLLAPSED_EXPLORE_HEADER = 'collapsed_explore_header', LAUNCHPAD_VERIFICATION = 'launchpad_verification', - FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting' + FAN_CLUB_TEXT_POST_POSTING = 'fan_club_text_post_posting', + CONTESTS = 'contests' } type FlagDefaults = Record @@ -25,7 +26,8 @@ export const environmentFlagDefaults: Record< Partial > = { development: { - [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: true + [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: true, + [FeatureFlags.CONTESTS]: true }, production: {} } @@ -47,5 +49,6 @@ export const flagDefaults: FlagDefaults = { [FeatureFlags.REACT_QUERY_SYNC]: false, [FeatureFlags.COLLAPSED_EXPLORE_HEADER]: false, [FeatureFlags.LAUNCHPAD_VERIFICATION]: true, - [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: false + [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: false, + [FeatureFlags.CONTESTS]: false } diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index 7cf6d07a3f2..a5e3d388cc4 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -31,6 +31,7 @@ export const TRENDING_PLAYLISTS_PAGE_LEGACY = '/trending/playlists' export const EXPLORE_PAGE = '/explore' export const TRENDING_PLAYLISTS_PAGE = '/explore/playlists' export const TRENDING_UNDERGROUND_PAGE = '/explore/underground' +export const CONTESTS_PAGE = '/contests' // DEPRECATED - use /library instead. export const SAVED_PAGE = '/favorites' @@ -294,6 +295,7 @@ export const orderedRoutes = [ TRENDING_GENRES, TRENDING_PAGE, EXPLORE_PAGE, + CONTESTS_PAGE, EMPTY_PAGE, SEARCH_PAGE, UPLOAD_ALBUM_PAGE, @@ -347,6 +349,7 @@ export const staticRoutes = new Set([ FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, + CONTESTS_PAGE, TRENDING_PLAYLISTS_PAGE, TRENDING_PLAYLISTS_PAGE_LEGACY, TRENDING_UNDERGROUND_PAGE, diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/meteora/swap_coin_quote.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/meteora/swap_coin_quote.ts index 58360ff6494..ea8ce4ef280 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/meteora/swap_coin_quote.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/meteora/swap_coin_quote.ts @@ -1,4 +1,4 @@ -import { CpAmm } from '@meteora-ag/cp-amm-sdk' +import { CpAmm, SwapMode as DammSwapMode } from '@meteora-ag/cp-amm-sdk' import { DynamicBondingCurveClient, SwapMode @@ -97,13 +97,17 @@ const getDammPoolQuote = async ( const inputTokenMint = swapDirection === 'audioToCoin' ? audioMintPubkey : coinMintPubkey - const quote = await cpAmm.getQuote({ - inAmount: inputAmountBN, + const currentSlot = await connection.getSlot() + const currentPoint = poolState.activationType + ? new BN(new Date().getTime()) + : new BN(currentSlot) + + const quote = cpAmm.getQuote2({ + amountIn: inputAmountBN, inputTokenMint, slippage: 2, poolState, - currentTime: new Date().getTime(), - currentSlot: await connection.getSlot(), + currentPoint, inputTokenInfo: { mint: tokenAMintInfo, currentEpoch @@ -113,10 +117,12 @@ const getDammPoolQuote = async ( currentEpoch }, tokenADecimal: tokenAMintInfo.decimals, - tokenBDecimal: tokenBMintInfo.decimals + tokenBDecimal: tokenBMintInfo.decimals, + swapMode: DammSwapMode.PartialFill, + hasReferral: false }) - return quote.swapOutAmount.toString() + return quote.outputAmount.toString() } /** * Gets a quote for swapping AUDIO to/from an artist coin using Meteora's DBC diff --git a/packages/mobile/src/components/contest-card/ContestCard.tsx b/packages/mobile/src/components/contest-card/ContestCard.tsx new file mode 100644 index 00000000000..4bc4efa81db --- /dev/null +++ b/packages/mobile/src/components/contest-card/ContestCard.tsx @@ -0,0 +1,329 @@ +import { useCallback, useEffect, useState } from 'react' + +import { + useRemixContest, + useRemixes, + useTrack, + useUser +} from '@audius/common/api' +import { SquareSizes } from '@audius/common/models' +import type { ID } from '@audius/common/models' +import { useNavigation } from '@react-navigation/native' +import type { NativeStackNavigationProp } from '@react-navigation/native-stack' +import { ScrollView, View } from 'react-native' +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated' + +import { + Divider, + Flex, + Image, + Paper, + type PaperProps, + Skeleton, + Text, + useTheme +} from '@audius/harmony-native' +import { ProfilePicture } from 'app/components/core' +import type { AppTabScreenParamList } from 'app/screens/app-screen' + +import { useTrackImage } from '../image/TrackImage' +import { UserLink } from '../user-link' + +const AnimatedImage = Animated.createAnimatedComponent(Image) + +const messages = { + hostedBy: 'HOSTED BY', + endsToday: 'ENDS TODAY', + ended: 'ENDED', + daysLeft: (n: number) => `${n} ${n === 1 ? 'DAY' : 'DAYS'} LEFT`, + entries: (n: number) => `${n} ${n === 1 ? 'ENTRY' : 'ENTRIES'}`, + prizesAvailable: 'PRIZES AVAILABLE' +} + +const formatStatus = (endDate?: string | null): string => { + if (!endDate) return messages.endsToday + const now = Date.now() + const end = new Date(endDate).getTime() + const diffMs = end - now + if (diffMs <= 0) return messages.ended + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)) + if (diffDays <= 1) return messages.endsToday + return messages.daysLeft(diffDays - 1) +} + +export type ContestCardVariant = 'hero' | 'grid' + +type ContestCardProps = PaperProps & { + /** + * The parent track ID the contest is attached to. The card resolves the + * remix-contest event (end date, prize info) internally via useRemixContest. + */ + trackId: ID + variant?: ContestCardVariant + noNavigation?: boolean +} + +type NavigationProp = NativeStackNavigationProp + +const COVER_HEIGHT = 96 + +/** + * The mobile Skeleton component fills its parent via position:absolute, so each + * skeleton placeholder needs a relatively-positioned sizing container. + */ +const SkeletonBlock = ({ + height, + width, + borderRadius +}: { + height: number + width: number | `${number}%` + borderRadius?: number +}) => ( + + + +) + +export const ContestCardSkeleton = ( + props: { variant?: ContestCardVariant } & Omit +) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + ) +} + +const Pill = ({ + label, + tone = 'subdued' +}: { + label: string + tone?: 'default' | 'subdued' +}) => ( + + + {label} + + +) + +/** + * Banner-aspect cover for the contest card. Mirrors the loading pattern of + * harmony-native's `Artwork` component (skeleton overlay, fade-in on load) + * but sized as a fixed-height banner instead of a square. + */ +const ContestCover = ({ + source, + children +}: { + source: ReturnType['source'] + children?: React.ReactNode +}) => { + const { color, motion } = useTheme() + const [isLoaded, setIsLoaded] = useState(false) + const opacity = useSharedValue(0) + + useEffect(() => { + setIsLoaded(false) + opacity.value = 0 + }, [source, opacity]) + + useEffect(() => { + if (isLoaded) { + opacity.value = withTiming(1, motion.expressive) + } + }, [isLoaded, opacity, motion.expressive]) + + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })) + + return ( + + {!isLoaded ? ( + + ) : null} + {source ? ( + setIsLoaded(true)} + onError={() => setIsLoaded(true)} + style={[ + { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%' + }, + animatedStyle + ]} + /> + ) : null} + + {children} + + + ) +} + +export const ContestCard = (props: ContestCardProps) => { + const { trackId, variant = 'grid', noNavigation, ...other } = props + + const { data: track } = useTrack(trackId) + const { data: user } = useUser(track?.owner_id) + const { data: remixContest } = useRemixContest(trackId) + + // Hero is full-width of the device, so always request the largest size the + // SDK exposes (1000×1000). Grid cards are narrower so 480×480 is enough. + const { source: coverSource } = useTrackImage({ + trackId, + size: + variant === 'hero' + ? SquareSizes.SIZE_1000_BY_1000 + : SquareSizes.SIZE_480_BY_480 + }) + + const { data: remixesData } = useRemixes( + { trackId, pageSize: 1, isContestEntry: true }, + { enabled: !!trackId } + ) + const entriesCount = remixesData?.pages?.[0]?.count ?? 0 + + const navigation = useNavigation() + const handlePress = useCallback(() => { + if (noNavigation || !trackId) return + navigation.navigate('Track', { trackId }) + }, [navigation, trackId, noNavigation]) + + if (!track || !user || !remixContest) { + return null + } + + const prizeInfo = remixContest.eventData?.prizeInfo + const status = formatStatus(remixContest.endDate) + + return ( + + {/* Cover banner */} + + + + + {/* Content */} + + + + + + {messages.hostedBy} + + + + + + + + + + + + {track.title} + + + + {prizeInfo ? : null} + + + + + ) +} diff --git a/packages/mobile/src/components/contest-card/index.ts b/packages/mobile/src/components/contest-card/index.ts new file mode 100644 index 00000000000..0c70e2080ff --- /dev/null +++ b/packages/mobile/src/components/contest-card/index.ts @@ -0,0 +1,2 @@ +export { ContestCard, ContestCardSkeleton } from './ContestCard' +export type { ContestCardVariant } from './ContestCard' diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/ContestsNavItem.tsx b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/ContestsNavItem.tsx new file mode 100644 index 00000000000..56da5c932dc --- /dev/null +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/ContestsNavItem.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' + +import { IconRemix } from '@audius/harmony-native' + +import { LeftNavLink } from './LeftNavLink' + +const messages = { + contests: 'Contests' +} + +export const ContestsNavItem = () => { + const { isEnabled: isContestsPageEnabled } = useFeatureFlag( + FeatureFlags.CONTESTS + ) + + if (!isContestsPageEnabled) return null + + return ( + + ) +} diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/FeatureFlagsNavItem.tsx b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/FeatureFlagsNavItem.tsx index 54c44911e28..badc65ac912 100644 --- a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/FeatureFlagsNavItem.tsx +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/FeatureFlagsNavItem.tsx @@ -16,8 +16,11 @@ export const FeatureFlagsNavItem = () => { FeatureFlags.FEATURE_FLAG_ACCESS ) - // Only show if feature flag access is enabled - if (!isFeatureFlagAccessEnabled) { + // Debug builds (e.g. `npm run ios:prod` on simulator) use production env/Optimizely + // defaults where `feature_flag_access` is off — still expose overrides for local dev. + const showFeatureFlags = isFeatureFlagAccessEnabled || __DEV__ + + if (!showFeatureFlags) { return null } diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/LeftNavDrawer.tsx b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/LeftNavDrawer.tsx index 3ff90407955..aa29dae73a5 100644 --- a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/LeftNavDrawer.tsx +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/LeftNavDrawer.tsx @@ -13,6 +13,7 @@ import { AccountDetails } from './AccountDetails' import { VanityMetrics } from './VanityMetrics' import { ProfileNavItem, + ContestsNavItem, MessagesNavItem, WalletNavItem, ArtistCoinsNavItem, @@ -48,6 +49,7 @@ const WrappedLeftNavDrawer = () => { + diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/nav-items/index.ts b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/nav-items/index.ts index 497a21ba6ed..c6e929b8327 100644 --- a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/nav-items/index.ts +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/nav-items/index.ts @@ -1,4 +1,5 @@ export { ProfileNavItem } from '../ProfileNavItem' +export { ContestsNavItem } from '../ContestsNavItem' export { MessagesNavItem } from '../MessagesNavItem' export { WalletNavItem } from '../WalletNavItem' export { ArtistCoinsNavItem } from '../ArtistCoinsNavItem' diff --git a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx index d7302c7001c..0e3a228ab9f 100644 --- a/packages/mobile/src/screens/app-screen/AppTabScreen.tsx +++ b/packages/mobile/src/screens/app-screen/AppTabScreen.tsx @@ -66,6 +66,7 @@ import { WalletScreen } from 'app/screens/wallet-screen' import { ArtistCoinSortScreen } from '../artist-coin-sort-screen/ArtistCoinSortScreen' import { ArtistCoinsExploreScreen } from '../artist-coins-explore-screen/ArtistCoinsExploreScreen' +import { ContestsScreen } from '../contests-screen' import { useAppScreenOptions } from './useAppScreenOptions' @@ -112,6 +113,7 @@ export type AppTabScreenParamList = { AudioScreen: undefined RewardsScreen: undefined + Contests: undefined ArtistCoinsExplore: undefined ArtistCoinSort: { initialSortMethod?: GetCoinsSortMethodEnum @@ -240,6 +242,7 @@ export const AppTabScreen = ({ baseScreen, Stack }: AppTabScreenProps) => { + diff --git a/packages/mobile/src/screens/contests-screen/ContestsScreen.tsx b/packages/mobile/src/screens/contests-screen/ContestsScreen.tsx new file mode 100644 index 00000000000..0a0c0872503 --- /dev/null +++ b/packages/mobile/src/screens/contests-screen/ContestsScreen.tsx @@ -0,0 +1,78 @@ +import React from 'react' + +import { useAllRemixContests } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' + +import { Flex, IconRemix, Text } from '@audius/harmony-native' +import { ContestCard, ContestCardSkeleton } from 'app/components/contest-card' +import { Screen, ScreenContent, ScrollView } from 'app/components/core' + +const messages = { + title: 'Contests', + empty: 'There are no contests right now. Check back soon!' +} + +const HERO_SKELETON_COUNT = 1 +const GRID_SKELETON_COUNT = 4 + +export const ContestsScreen = () => { + const { isEnabled: isContestsPageEnabled } = useFeatureFlag( + FeatureFlags.CONTESTS + ) + const { data, isPending, isError, isSuccess } = useAllRemixContests( + undefined, + { enabled: isContestsPageEnabled } + ) + + const contests = isContestsPageEnabled ? (data ?? []) : [] + const [heroTrackId, ...gridTrackIds] = contests + const showSkeletons = + isContestsPageEnabled && (isPending || (!isSuccess && !isError)) + const showEmpty = isSuccess && contests.length === 0 + + return ( + + + + + {showSkeletons ? ( + + {Array.from({ length: HERO_SKELETON_COUNT }).map((_, i) => ( + + ))} + {Array.from({ length: GRID_SKELETON_COUNT }).map((_, i) => ( + + ))} + + ) : showEmpty ? ( + + {messages.empty} + + ) : ( + + {heroTrackId != null ? ( + + ) : null} + {gridTrackIds.map((id) => ( + + ))} + + )} + + + + + ) +} diff --git a/packages/mobile/src/screens/contests-screen/index.ts b/packages/mobile/src/screens/contests-screen/index.ts new file mode 100644 index 00000000000..5f57b27b778 --- /dev/null +++ b/packages/mobile/src/screens/contests-screen/index.ts @@ -0,0 +1 @@ +export { ContestsScreen } from './ContestsScreen' diff --git a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx index 6a4de1b5647..c8eb8a4f607 100644 --- a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx +++ b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx @@ -28,10 +28,10 @@ export const ExploreContent = () => { {showTrackContent && showUserContextualContent && } {showPlaylistContent && } + {showTrackContent && } {showTrackContent && showUserContextualContent && ( )} - {showTrackContent && } {showUserContent && } {showUserContent && } {showTrackContent && showUserContextualContent && } diff --git a/packages/mobile/src/screens/explore-screen/components/FeaturedRemixContests.tsx b/packages/mobile/src/screens/explore-screen/components/FeaturedRemixContests.tsx index f71a745ed1f..9cfc53388a6 100644 --- a/packages/mobile/src/screens/explore-screen/components/FeaturedRemixContests.tsx +++ b/packages/mobile/src/screens/explore-screen/components/FeaturedRemixContests.tsx @@ -1,9 +1,12 @@ import React from 'react' import { useExploreContent, useTracks } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' import { useTheme } from '@audius/harmony-native' +import { ContestCard } from 'app/components/contest-card' import { CardList } from 'app/components/core' import { RemixContestCard } from 'app/components/remix-carousel/RemixContestCard' import { TrackCardSkeleton } from 'app/components/track/TrackCardSkeleton' @@ -15,25 +18,51 @@ import { ExploreSection } from './ExploreSection' export const FeaturedRemixContests = () => { const { spacing } = useTheme() const { InViewWrapper, inView } = useDeferredElement() + const { isEnabled: isContestsPageEnabled } = useFeatureFlag( + FeatureFlags.CONTESTS + ) + + const { data: exploreContent, isPending: isExplorePending } = + useExploreContent({ enabled: inView }) - const { data: exploreContent } = useExploreContent({ enabled: inView }) + // Old-card path needs the hydrated track list; new-card path resolves per + // card internally so we skip this fetch when the flag is enabled. const { data: remixContests } = useTracks( exploreContent?.featuredRemixContests, - { enabled: inView } + { enabled: inView && !isContestsPageEnabled } ) return ( - - ({ trackId: track.track_id }))} - renderItem={({ item }) => { - return - }} - horizontal - carouselSpacing={spacing.l} - LoadingCardComponent={TrackCardSkeleton} - /> + + {isContestsPageEnabled ? ( + ({ + trackId + }))} + renderItem={({ item }) => } + horizontal + carouselSpacing={spacing.l} + isLoading={isExplorePending} + LoadingCardComponent={TrackCardSkeleton} + /> + ) : ( + ({ trackId: track.track_id }))} + renderItem={({ item }) => ( + + )} + horizontal + carouselSpacing={spacing.l} + LoadingCardComponent={TrackCardSkeleton} + /> + )} ) diff --git a/packages/sdk/src/sdk/api/generated/default/apis/EventsApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/EventsApi.ts index 97b9f4f9f45..b7567877c62 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/EventsApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/EventsApi.ts @@ -48,6 +48,12 @@ export interface GetEntityEventsRequest { filterDeleted?: boolean; } +export interface GetRemixContestsRequest { + offset?: number; + limit?: number; + status?: GetRemixContestsStatusEnum; +} + /** * */ @@ -219,6 +225,54 @@ export class EventsApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Get remix contest events ordered with currently-active contests first (by soonest-ending), followed by ended contests (most-recently-ended first). Active contests are those whose end_date is null or in the future. + * Get all remix contests + */ + async getRemixContestsRaw(params: GetRemixContestsRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise> { + const queryParameters: any = {}; + + if (params.offset !== undefined) { + queryParameters['offset'] = params.offset; + } + + if (params.limit !== undefined) { + queryParameters['limit'] = params.limit; + } + + if (params.status !== undefined) { + queryParameters['status'] = params.status; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (!headerParameters["Authorization"] && this.configuration && this.configuration.accessToken) { + const token = await this.configuration.accessToken("OAuth2", ["read"]); + if (token) { + headerParameters["Authorization"] = token; + } + } + + const response = await this.request({ + path: `/events/remix-contests`, + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => EventsResponseFromJSON(jsonValue)); + } + + /** + * Get remix contest events ordered with currently-active contests first (by soonest-ending), followed by ended contests (most-recently-ended first). Active contests are those whose end_date is null or in the future. + * Get all remix contests + */ + async getRemixContests(params: GetRemixContestsRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getRemixContestsRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets an unclaimed blockchain event ID @@ -283,3 +337,12 @@ export const GetEntityEventsEntityTypeEnum = { User: 'user' } as const; export type GetEntityEventsEntityTypeEnum = typeof GetEntityEventsEntityTypeEnum[keyof typeof GetEntityEventsEntityTypeEnum]; +/** + * @export + */ +export const GetRemixContestsStatusEnum = { + Active: 'active', + Ended: 'ended', + All: 'all' +} as const; +export type GetRemixContestsStatusEnum = typeof GetRemixContestsStatusEnum[keyof typeof GetRemixContestsStatusEnum]; diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index da2ac4e06bd..e3cce9f041b 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -201,6 +201,11 @@ const ExplorePage = lazy(() => default: m.ExplorePage })) ) +const ContestsPage = lazy(() => + import('pages/contests-page/ContestsPage').then((m) => ({ + default: m.ContestsPage + })) +) const SettingsPage = lazy(() => import('pages/settings-page/SettingsPage')) const TrackCommentsPage = lazy(() => import('pages/track-page/TrackCommentsPage').then((m) => ({ @@ -220,6 +225,7 @@ const { NOTIFICATION_PAGE, NOTIFICATION_USERS_PAGE, EXPLORE_PAGE, + CONTESTS_PAGE, SAVED_PAGE, LIBRARY_PAGE, LIBRARY_TRACKS_PAGE, @@ -817,6 +823,7 @@ const WebPlayer = (props: WebPlayerProps) => { element={} /> } /> + } /> } @@ -1289,6 +1296,7 @@ const WebPlayer = (props: WebPlayerProps) => { element={} /> } /> + } /> } diff --git a/packages/web/src/components/contest-card/ContestCard.tsx b/packages/web/src/components/contest-card/ContestCard.tsx new file mode 100644 index 00000000000..d870301b26b --- /dev/null +++ b/packages/web/src/components/contest-card/ContestCard.tsx @@ -0,0 +1,337 @@ +import { + MouseEvent, + ReactNode, + Ref, + forwardRef, + useCallback, + useEffect, + useState +} from 'react' + +import { + useRemixContest, + useRemixes, + useTrack, + useUser +} from '@audius/common/api' +import { ID, SquareSizes } from '@audius/common/models' +import { + Divider, + Flex, + Paper, + type PaperProps, + Skeleton, + Text, + useTheme +} from '@audius/harmony' +import { useLinkClickHandler } from 'react-router' + +import { Avatar } from 'components/avatar/Avatar' +import { UserLink } from 'components/link' +import { useTrackCoverArt } from 'hooks/useTrackCoverArt' + +const messages = { + hostedBy: 'HOSTED BY', + endsToday: 'ENDS TODAY', + ended: 'ENDED', + daysLeft: (n: number) => `${n} ${n === 1 ? 'DAY' : 'DAYS'} LEFT`, + entries: (n: number) => `${n} ${n === 1 ? 'ENTRY' : 'ENTRIES'}`, + prizesAvailable: 'PRIZES AVAILABLE' +} + +const formatStatus = (endDate?: string | null): string => { + if (!endDate) return messages.endsToday + const now = Date.now() + const end = new Date(endDate).getTime() + const diffMs = end - now + if (diffMs <= 0) return messages.ended + const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)) + if (diffDays <= 1) return messages.endsToday + return messages.daysLeft(diffDays - 1) +} + +export type ContestCardVariant = 'hero' | 'grid' + +export type ContestCardProps = Omit & { + /** + * The parent track ID the contest is attached to. The card resolves the + * remix-contest event (end date, prize info) internally via useRemixContest. + */ + trackId: ID + variant?: ContestCardVariant + onClick?: (e: MouseEvent) => void +} + +const COVER_HEIGHT = 96 + +/** + * Renders the contest card cover as a proper `` tag (instead of a CSS + * background-image). Using a real image element lets the browser apply its + * native image scaling on HiDPI displays, which is meaningfully crisper for + * the wide hero variant. A Skeleton overlay fades out when the image loads. + */ +const ContestCover = ({ + src, + children +}: { + src?: string + children?: ReactNode +}) => { + const { motion } = useTheme() + const [isLoaded, setIsLoaded] = useState(false) + + useEffect(() => { + setIsLoaded(false) + }, [src]) + + return ( + ({ + position: 'relative', + overflow: 'hidden', + backgroundColor: theme.color.background.surface2, + borderBottom: `1px solid ${theme.color.border.strong}` + })} + > + {!isLoaded ? ( + + ) : null} + {src ? ( + setIsLoaded(true)} + onError={() => setIsLoaded(true)} + css={{ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center', + opacity: isLoaded ? 1 : 0, + transition: `opacity ${motion.calm}` + }} + /> + ) : null} + {children ? ( + + {children} + + ) : null} + + ) +} + +const CardPill = ({ children }: { children: ReactNode }) => { + const { color } = useTheme() + return ( + + + {children} + + + ) +} + +const StatusPill = ({ children }: { children: ReactNode }) => { + const { color } = useTheme() + return ( + + + {children} + + + ) +} + +export const ContestCardSkeleton = ( + props: { variant?: ContestCardVariant } & Omit +) => { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} + +export const ContestCard = forwardRef( + (props: ContestCardProps, ref: Ref) => { + const { trackId, variant = 'grid', onClick, ...other } = props + + const { data: track } = useTrack(trackId) + const { data: user } = useUser(track?.owner_id) + const { data: remixContest } = useRemixContest(trackId) + // Hero is full-width (~960px+ at 2x DPI ≈ 1920px), so always request the + // largest size the SDK exposes (1000×1000). Grid cards are ~309px wide so + // 480×480 is a much better size/fidelity tradeoff. + const { imageUrl } = useTrackCoverArt({ + trackId, + size: + variant === 'hero' + ? SquareSizes.SIZE_1000_BY_1000 + : SquareSizes.SIZE_480_BY_480 + }) + + const { data: remixesData } = useRemixes( + { trackId, pageSize: 1, isContestEntry: true }, + { enabled: !!trackId } + ) + const entriesCount = remixesData?.pages?.[0]?.count ?? 0 + + const permalink = track?.permalink ?? '' + const handleNavigate = useLinkClickHandler(permalink) + const handleClick = useCallback( + (e: MouseEvent) => { + onClick?.(e) + if (permalink) handleNavigate(e) + }, + [handleNavigate, onClick, permalink] + ) + + if (!track || !user || !remixContest) { + return + } + + const prizeInfo = remixContest.eventData?.prizeInfo + const status = formatStatus(remixContest.endDate) + + return ( + + {/* Cover banner */} + + {status} + + + {/* Content */} + + + + + + {messages.hostedBy} + + + + + + + + + + {track.title} + + e.stopPropagation()} + css={{ + overflowX: 'auto', + overflowY: 'hidden', + minWidth: 0, + scrollbarWidth: 'none', + msOverflowStyle: 'none', + '&::-webkit-scrollbar': { display: 'none' } + }} + > + {messages.entries(entriesCount)} + {prizeInfo ? ( + {messages.prizesAvailable} + ) : null} + + + + + ) + } +) diff --git a/packages/web/src/components/contest-card/index.ts b/packages/web/src/components/contest-card/index.ts new file mode 100644 index 00000000000..10f926c574e --- /dev/null +++ b/packages/web/src/components/contest-card/index.ts @@ -0,0 +1,2 @@ +export { ContestCard, ContestCardSkeleton } from './ContestCard' +export type { ContestCardProps, ContestCardVariant } from './ContestCard' diff --git a/packages/web/src/components/nav/desktop/LeftNav.tsx b/packages/web/src/components/nav/desktop/LeftNav.tsx index 7d9ed1a3146..28aece9175a 100644 --- a/packages/web/src/components/nav/desktop/LeftNav.tsx +++ b/packages/web/src/components/nav/desktop/LeftNav.tsx @@ -18,6 +18,7 @@ import { FeedNavItem, TrendingNavItem, ExploreNavItem, + ContestsNavItem, LibraryNavItem, MessagesNavItem, WalletNavItem, @@ -116,6 +117,7 @@ export const LeftNav = (props: OwnProps) => { + diff --git a/packages/web/src/components/nav/desktop/nav-items/ContestsNavItem.tsx b/packages/web/src/components/nav/desktop/nav-items/ContestsNavItem.tsx new file mode 100644 index 00000000000..bf542c29d09 --- /dev/null +++ b/packages/web/src/components/nav/desktop/nav-items/ContestsNavItem.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' +import { route } from '@audius/common/utils' +import { IconRemix } from '@audius/harmony' + +import { LeftNavLink } from '../LeftNavLink' +import { NavSpeakerIcon } from '../NavSpeakerIcon' +import { useNavSourcePlayingStatus } from '../useNavSourcePlayingStatus' + +const { CONTESTS_PAGE } = route + +export const ContestsNavItem = () => { + const { isEnabled: isContestsPageEnabled } = useFeatureFlag( + FeatureFlags.CONTESTS + ) + const playingFromRoute = useNavSourcePlayingStatus() + + if (!isContestsPageEnabled) return null + + return ( + + } + > + Contests + + ) +} diff --git a/packages/web/src/components/nav/desktop/nav-items/index.ts b/packages/web/src/components/nav/desktop/nav-items/index.ts index a9e85b3042e..8a17d399d9c 100644 --- a/packages/web/src/components/nav/desktop/nav-items/index.ts +++ b/packages/web/src/components/nav/desktop/nav-items/index.ts @@ -1,6 +1,7 @@ export { FeedNavItem } from './FeedNavItem' export { TrendingNavItem } from './TrendingNavItem' export { ExploreNavItem } from './ExploreNavItem' +export { ContestsNavItem } from './ContestsNavItem' export { LibraryNavItem } from './LibraryNavItem' export { MessagesNavItem } from './MessagesNavItem' export { WalletNavItem } from './WalletNavItem' diff --git a/packages/web/src/pages/contests-page/ContestsPage.tsx b/packages/web/src/pages/contests-page/ContestsPage.tsx new file mode 100644 index 00000000000..3cfe729b1a0 --- /dev/null +++ b/packages/web/src/pages/contests-page/ContestsPage.tsx @@ -0,0 +1,142 @@ +import { useAllRemixContests } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' +import { Box, Button, Flex, IconRemix, Text } from '@audius/harmony' +import { Navigate } from 'react-router' + +import { ContestCard, ContestCardSkeleton } from 'components/contest-card' +import { Header } from 'components/header/desktop/Header' +import Page from 'components/page/Page' +import { useIsMobile } from 'hooks/useIsMobile' + +import { RunYourOwnContestBanner } from './RunYourOwnContestBanner' + +const CONTEST_HOSTING_HELP_URL = + 'https://help.audius.co/artists/hosting-a-remix-contest' + +const messages = { + title: 'Contests', + description: + 'Discover remix contests from artists across Audius and submit your remix.', + empty: 'There are no contests right now. Check back soon!', + createContest: 'Create Contest' +} + +const HERO_SKELETON_COUNT = 1 +const GRID_SKELETON_COUNT = 11 + +export const ContestsPage = () => { + const isMobile = useIsMobile() + const { isEnabled: isContestsPageEnabled, isLoaded: isFlagLoaded } = + useFeatureFlag(FeatureFlags.CONTESTS) + const { data, isPending, isError, isSuccess } = useAllRemixContests( + undefined, + { enabled: isContestsPageEnabled } + ) + + if (isFlagLoaded && !isContestsPageEnabled) { + return + } + + const contests = data ?? [] + const showSkeletons = isPending || (!isSuccess && !isError) + const showEmpty = isSuccess && contests.length === 0 + + const [heroTrackId, ...gridTrackIds] = contests + + const header = ( +
+ + {messages.createContest} + + + } + /> + ) + + return ( + + + {showEmpty ? ( + + + {messages.empty} + + + ) : showSkeletons ? ( + + {Array.from({ length: HERO_SKELETON_COUNT }).map((_, i) => ( + + ))} + + {Array.from({ length: GRID_SKELETON_COUNT }).map((_, i) => ( + + ))} + + + ) : ( + + {heroTrackId != null ? ( + + ) : null} + {gridTrackIds.length > 0 ? ( + + {gridTrackIds.map((id) => ( + + ))} + + ) : null} + + )} + + {!showEmpty ? : null} + + + ) +} + +export default ContestsPage diff --git a/packages/web/src/pages/contests-page/RunYourOwnContestBanner.tsx b/packages/web/src/pages/contests-page/RunYourOwnContestBanner.tsx new file mode 100644 index 00000000000..908900e48be --- /dev/null +++ b/packages/web/src/pages/contests-page/RunYourOwnContestBanner.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react' + +import { + Button, + Flex, + IconButton, + IconClose, + Paper, + Text +} from '@audius/harmony' + +import { useIsMobile } from 'hooks/useIsMobile' + +const CONTEST_HOSTING_HELP_URL = + 'https://help.audius.co/artists/hosting-a-remix-contest' + +const messages = { + title: 'Run Your Own Contest!', + description: + 'Host a remix contest for members of the community. Add stems, accept submissions, offer prizes, and more!', + createContest: 'Create Contest', + dismiss: 'Dismiss' +} + +/** + * Desktop-only bottom-of-page CTA encouraging viewers to host their own remix + * contest. Hidden on mobile-width viewports. Dismissable for the rest of the + * session (state is not persisted across reloads — flip to localStorage if we + * want it to stick). + */ +export const RunYourOwnContestBanner = () => { + const isMobile = useIsMobile() + const [isDismissed, setIsDismissed] = useState(false) + + if (isMobile || isDismissed) return null + + return ( + + + + {messages.title} + + + {messages.description} + + + + + + setIsDismissed(true)} + css={{ position: 'absolute', top: 8, right: 8 }} + /> + + ) +} diff --git a/packages/web/src/pages/contests-page/index.ts b/packages/web/src/pages/contests-page/index.ts new file mode 100644 index 00000000000..41677d3a4cb --- /dev/null +++ b/packages/web/src/pages/contests-page/index.ts @@ -0,0 +1 @@ +export { ContestsPage } from './ContestsPage' diff --git a/packages/web/src/pages/search-explore-page/components/desktop/FeaturedRemixContestsSection.tsx b/packages/web/src/pages/search-explore-page/components/desktop/FeaturedRemixContestsSection.tsx index 2eb0f9cbff1..dc9aff82c4c 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/FeaturedRemixContestsSection.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/FeaturedRemixContestsSection.tsx @@ -1,6 +1,10 @@ import { useExploreContent } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' +import { FeatureFlags } from '@audius/common/services' +import { Box } from '@audius/harmony' +import { ContestCard, ContestCardSkeleton } from 'components/contest-card' import { RemixContestCard, RemixContestCardSkeleton @@ -10,31 +14,58 @@ import { useIsMobile } from 'hooks/useIsMobile' import { Carousel } from './Carousel' import { useExploreSectionTracking } from './useExploreSectionTracking' +const NEW_CARD_WIDTH = 320 +const SKELETON_COUNT = 6 + export const FeaturedRemixContestsSection = () => { const { ref, inView } = useExploreSectionTracking('Featured Remix Contests') - const { data, isLoading, isError, isSuccess } = useExploreContent({ + const { data, isPending, isError, isSuccess } = useExploreContent({ enabled: inView }) const isMobile = useIsMobile() + const { isEnabled: isContestsPageEnabled } = useFeatureFlag( + FeatureFlags.CONTESTS + ) if (isError || (isSuccess && !data?.featuredRemixContests?.length)) { return null } + const showLoading = !inView || !data?.featuredRemixContests || isPending + return ( - - {!inView || !data?.featuredRemixContests || isLoading - ? Array.from({ length: 6 }).map((_, i) => ( - - )) - : data?.featuredRemixContests?.map((id) => ( - - ))} + + {isContestsPageEnabled + ? showLoading + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + + + )) + : data.featuredRemixContests.map((trackId) => ( + + + + )) + : showLoading + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : data.featuredRemixContests.map((id) => ( + + ))} ) } diff --git a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx index 9eccb4bac9f..5b58a5781aa 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx @@ -322,6 +322,7 @@ const SearchExplorePage = ({ ) : null} {showPlaylistContent ? : null} + {showTrackContent ? : null} {categoryKey === CategoryView.ALL ? ( ) : null} @@ -329,7 +330,6 @@ const SearchExplorePage = ({ {showTrackContent && showUserContextualContent ? ( ) : null} - {showTrackContent ? : null} {isTracksTab ? : null} {showUserContent ? : null} {showUserContent ? : null} diff --git a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx index 0302d8ec854..b3f6f7ef282 100644 --- a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx @@ -252,8 +252,8 @@ const SearchExplorePage = ({ ) : null} {showPlaylistContent ? : null} - {categoryKey === CategoryView.ALL ? : null} {showTrackContent ? : null} + {categoryKey === CategoryView.ALL ? : null} {isTracksTab ? : null} {showUserContent ? : null} {showUserContent ? : null}