From 69efadc92d9b460375b071f7b641e3b09df3c2ae Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 8 May 2026 13:49:22 -0500 Subject: [PATCH 1/9] Add home page to web (mobile and desktop) also includes a profile completion design overhaul + relocates it to home page (desktop only) --- packages/common/src/utils/route.ts | 3 + packages/harmony/src/assets/icons/Home.svg | 3 + packages/harmony/src/icons/icons.tsx | 1 + .../harmony/src/icons/individual/IconHome.ts | 5 + .../project.pbxproj | 2 +- packages/mobile/ios/Podfile | 72 ++++- packages/mobile/ios/Podfile.lock | 2 +- packages/web/src/app/web-player/WebPlayer.tsx | 26 +- .../animated-switch/AnimatedSwitch.tsx | 4 +- .../src/components/nav/desktop/LeftNav.tsx | 2 + .../nav/desktop/nav-items/HomeNavItem.tsx | 31 ++ .../components/nav/desktop/nav-items/index.ts | 1 + .../components/ProfileCompletionHeroCard.tsx | 271 ++++++++++------- .../components/TaskCompletionItem.tsx | 7 +- packages/web/src/pages/home-page/HomePage.tsx | 24 ++ .../components/ActiveContestsStrip.tsx | 83 ++++++ .../components/FromPeopleYouFollowSection.tsx | 45 +++ .../pages/home-page/components/QuickLinks.tsx | 278 ++++++++++++++++++ .../components/RewardsSummaryCard.tsx | 79 +++++ .../pages/home-page/components/StatusZone.tsx | 72 +++++ .../pages/home-page/components/UnauthHero.tsx | 43 +++ .../components/YourTopArtistsSection.tsx | 146 +++++++++ .../home-page/components/desktop/HomePage.tsx | 146 +++++++++ .../home-page/components/mobile/HomePage.tsx | 137 +++++++++ packages/web/src/pages/home-page/icon.ts | 3 + packages/web/src/pages/home-page/index.ts | 2 + .../components/desktop/ProfilePage.tsx | 6 - .../components/desktop/MoodGrid.tsx | 12 +- packages/web/src/ssr/util.ts | 3 +- .../web/src/store/lineup/lineupForRoute.js | 6 +- 30 files changed, 1389 insertions(+), 126 deletions(-) create mode 100644 packages/harmony/src/assets/icons/Home.svg create mode 100644 packages/harmony/src/icons/individual/IconHome.ts create mode 100644 packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx create mode 100644 packages/web/src/pages/home-page/HomePage.tsx create mode 100644 packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx create mode 100644 packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx create mode 100644 packages/web/src/pages/home-page/components/QuickLinks.tsx create mode 100644 packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx create mode 100644 packages/web/src/pages/home-page/components/StatusZone.tsx create mode 100644 packages/web/src/pages/home-page/components/UnauthHero.tsx create mode 100644 packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx create mode 100644 packages/web/src/pages/home-page/components/desktop/HomePage.tsx create mode 100644 packages/web/src/pages/home-page/components/mobile/HomePage.tsx create mode 100644 packages/web/src/pages/home-page/icon.ts create mode 100644 packages/web/src/pages/home-page/index.ts diff --git a/packages/common/src/utils/route.ts b/packages/common/src/utils/route.ts index 9b5b0d77902..80031b03092 100644 --- a/packages/common/src/utils/route.ts +++ b/packages/common/src/utils/route.ts @@ -52,6 +52,7 @@ export const UPLOAD_ALBUM_PAGE = '/upload/album' export const UPLOAD_PLAYLIST_PAGE = '/upload/playlist' export const SETTINGS_PAGE = '/settings' export const HOME_PAGE = '/' +export const HOMEPAGE_PAGE = '/home' export const NOT_FOUND_PAGE = '/404' export const SIGN_IN_PAGE = '/signin' export const SIGN_IN_CONFIRM_EMAIL_PAGE = '/signin/confirm-email' @@ -337,6 +338,7 @@ export const orderedRoutes = [ SALES_PAGE, WITHDRAWALS_PAGE, NOT_FOUND_PAGE, + HOMEPAGE_PAGE, HOME_PAGE, PLAYLIST_PAGE, ALBUM_PAGE, @@ -350,6 +352,7 @@ export const orderedRoutes = [ ] export const staticRoutes = new Set([ + HOMEPAGE_PAGE, FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, diff --git a/packages/harmony/src/assets/icons/Home.svg b/packages/harmony/src/assets/icons/Home.svg new file mode 100644 index 00000000000..2bffcc111a5 --- /dev/null +++ b/packages/harmony/src/assets/icons/Home.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/harmony/src/icons/icons.tsx b/packages/harmony/src/icons/icons.tsx index 1104bc4daea..69e9d75705c 100644 --- a/packages/harmony/src/icons/icons.tsx +++ b/packages/harmony/src/icons/icons.tsx @@ -12,6 +12,7 @@ export { IconGift } from './individual/IconGift' export { IconSettings } from './individual/IconSettings' export { IconArrowLeft } from './individual/IconArrowLeft' export { IconHeart } from './individual/IconHeart' +export { IconHome } from './individual/IconHome' export { IconShare } from './individual/IconShare' export { IconArrowRight } from './individual/IconArrowRight' export { IconMoneySend } from './individual/IconMoneySend' diff --git a/packages/harmony/src/icons/individual/IconHome.ts b/packages/harmony/src/icons/individual/IconHome.ts new file mode 100644 index 00000000000..89fbb636825 --- /dev/null +++ b/packages/harmony/src/icons/individual/IconHome.ts @@ -0,0 +1,5 @@ +import { IconComponent } from '~harmony/components' + +import IconSVG from '../../assets/icons/Home.svg' + +export const IconHome = IconSVG as IconComponent diff --git a/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj b/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj index 8af9e63935f..b1c1b820350 100644 --- a/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/AudiusReactNative.xcodeproj/project.pbxproj @@ -424,7 +424,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export NODE_BINARY=node\nWITH_ENVIRONMENT=\"${SRCROOT}/../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"${SRCROOT}/../node_modules/react-native/scripts/react-native-xcode.sh\"\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n"; + shellScript = "export NODE_BINARY=node\nWITH_ENVIRONMENT=\"${SRCROOT}/../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"${SRCROOT}/../node_modules/react-native/scripts/react-native-xcode.sh\"\n. \"$WITH_ENVIRONMENT\"\n\"$REACT_NATIVE_XCODE\"\n"; showEnvVarsInLog = 0; }; B73C21C532E1201FEFBAECDA /* [CP] Copy Pods Resources */ = { diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile index cff6c13dbf0..498aa611a1a 100644 --- a/packages/mobile/ios/Podfile +++ b/packages/mobile/ios/Podfile @@ -50,9 +50,8 @@ target 'AudiusReactNative' do def strip_bitcode_from_framework(bitcode_strip_path, framework_relative_path) framework_path = File.join(Dir.pwd, framework_relative_path) - command = "#{bitcode_strip_path} #{framework_path} -r -o #{framework_path}" - puts "Stripping bitcode: #{command}" - system(command) + puts "Stripping bitcode: #{bitcode_strip_path} #{framework_path} -r -o #{framework_path}" + system(bitcode_strip_path, framework_path, "-r", "-o", framework_path) end framework_paths = [ @@ -66,6 +65,53 @@ target 'AudiusReactNative' do strip_bitcode_from_framework(bitcode_strip_path, framework_relative_path) end + def patch_react_native_scripts_for_paths_with_spaces(react_native_path) + hermes_script_path = File.join(react_native_path, "sdks/hermes-engine/utils/replace_hermes_version.js") + hermes_contents = File.read(hermes_script_path) + patched_hermes_contents = hermes_contents + .gsub( + "const {execSync} = require('child_process');", + "const {execFileSync} = require('child_process');" + ) + .gsub( + ' execSync(`tar -xf ${tarballURLPath} -C ${finalLocation}`);', + " execFileSync('tar', ['-xf', tarballURLPath, '-C', finalLocation]);" + ) + + if patched_hermes_contents != hermes_contents + puts "Patching Hermes replacement script for paths with spaces" + File.write(hermes_script_path, patched_hermes_contents) + end + + environment_script_path = File.join(react_native_path, "scripts/xcode/with-environment.sh") + environment_contents = File.read(environment_script_path) + patched_environment_contents = environment_contents.gsub( + "if [ -n \"$1\" ]; then\n $1\nfi", + "if [ \"$#\" -gt 0 ]; then\n \"$@\"\nfi" + ) + + if patched_environment_contents != environment_contents + puts "Patching React Native Xcode environment script for paths with spaces" + File.write(environment_script_path, patched_environment_contents) + end + end + + def patch_script_phase_for_paths_with_spaces(script_phase) + return if script_phase.shell_script.nil? + + patched_script = script_phase.shell_script.gsub( + '/bin/sh -c "$WITH_ENVIRONMENT $SCRIPT_PHASES_SCRIPT"', + '"$WITH_ENVIRONMENT" "$SCRIPT_PHASES_SCRIPT"' + ) + + if patched_script != script_phase.shell_script + puts "Patching #{script_phase.display_name} script phase for paths with spaces" + script_phase.shell_script = patched_script + end + end + + patch_react_native_scripts_for_paths_with_spaces(config[:reactNativePath]) + # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( installer, @@ -81,6 +127,25 @@ target 'AudiusReactNative' do # https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1201464693 installer.pods_project.targets.each do |target| + target.shell_script_build_phases.each do |script_phase| + patch_script_phase_for_paths_with_spaces(script_phase) + end + + target.build_configurations.each do |config| + deployment_target = config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] + next if deployment_target.nil? + + if Gem::Version.new(deployment_target) < Gem::Version.new('15.5') + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' + end + end + + if target.name == "fmt" + target.build_configurations.each do |config| + config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'gnu++17' + end + end + if target.respond_to?(:product_type) and target.product_type == "com.apple.product-type.bundle" target.build_configurations.each do |config| config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO' @@ -101,4 +166,3 @@ target 'AudiusReactNative' do end end - diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 59d294c1897..f5cd9b1619d 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -2907,6 +2907,6 @@ SPEC CHECKSUMS: TOCropViewController: 80b8985ad794298fb69d3341de183f33d1853654 Yoga: eca8dd841b7cd47d82d66be58af8e3aeb819012f -PODFILE CHECKSUM: b77c7dc423273b965d32a03d73fe6277c67877b7 +PODFILE CHECKSUM: e09d6543ec0ac3bf17229efe67e5637bc9ad987b COCOAPODS: 1.16.2 diff --git a/packages/web/src/app/web-player/WebPlayer.tsx b/packages/web/src/app/web-player/WebPlayer.tsx index bfe344965a0..594b4571cda 100644 --- a/packages/web/src/app/web-player/WebPlayer.tsx +++ b/packages/web/src/app/web-player/WebPlayer.tsx @@ -154,6 +154,7 @@ const FbSharePage = lazy(() => })) ) const FeedPage = lazy(() => import('pages/feed-page/FeedPage')) +const HomePage = lazy(() => import('pages/home-page/HomePage')) const FollowersPage = lazy(() => import('pages/followers-page/FollowersPage')) const FollowingPage = lazy(() => import('pages/following-page/FollowingPage')) const HistoryPage = lazy(() => import('pages/history-page/HistoryPage')) @@ -245,6 +246,7 @@ const { UPLOAD_PLAYLIST_PAGE, SETTINGS_PAGE, HOME_PAGE, + HOMEPAGE_PAGE, NOT_FOUND_PAGE, SEARCH_PAGE, PLAYLIST_PAGE, @@ -427,9 +429,13 @@ const CoinExclusiveTracksLegacyRedirect = () => { type HomePageRedirectProps = { isGuestAccount: boolean + target?: string } -const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => { +const HomePageRedirect = ({ + isGuestAccount, + target = TRENDING_PAGE +}: HomePageRedirectProps) => { const location = useLocation() const currentPath = getPathname(location) const to = { @@ -437,7 +443,7 @@ const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => { currentPath === HOME_PAGE ? isGuestAccount ? LIBRARY_PAGE - : TRENDING_PAGE + : target : currentPath, search: includeSearch(location.search) ? location.search : '' } @@ -1282,9 +1288,15 @@ const WebPlayer = (props: WebPlayerProps) => { path={PROFILE_PAGE} element={} /> + } /> } + element={ + + } /> ) : ( @@ -1632,9 +1644,15 @@ const WebPlayer = (props: WebPlayerProps) => { path={PROFILE_PAGE} element={} /> + } /> } + element={ + + } /> )} diff --git a/packages/web/src/components/animated-switch/AnimatedSwitch.tsx b/packages/web/src/components/animated-switch/AnimatedSwitch.tsx index b112ab3de1b..054cc391eaf 100644 --- a/packages/web/src/components/animated-switch/AnimatedSwitch.tsx +++ b/packages/web/src/components/animated-switch/AnimatedSwitch.tsx @@ -26,12 +26,14 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE + LIBRARY_PAGE, + HOMEPAGE_PAGE } = route const DISABLED_PAGES = new Set([ SIGN_IN_PAGE, SIGN_UP_PAGE, + HOMEPAGE_PAGE, FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, diff --git a/packages/web/src/components/nav/desktop/LeftNav.tsx b/packages/web/src/components/nav/desktop/LeftNav.tsx index 49395522964..b2983b6aa9d 100644 --- a/packages/web/src/components/nav/desktop/LeftNav.tsx +++ b/packages/web/src/components/nav/desktop/LeftNav.tsx @@ -16,6 +16,7 @@ import { useNavSidebar } from './NavSidebarContext' import { NowPlayingArtworkTile } from './NowPlayingArtworkTile' import { RouteNav } from './RouteNav' import { + HomeNavItem, FeedNavItem, TrendingNavItem, ExploreNavItem, @@ -128,6 +129,7 @@ export const LeftNav = (props: OwnProps) => { flex='1 1 auto' css={{ overflow: 'hidden' }} > + diff --git a/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx b/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx new file mode 100644 index 00000000000..0ec45bb3fba --- /dev/null +++ b/packages/web/src/components/nav/desktop/nav-items/HomeNavItem.tsx @@ -0,0 +1,31 @@ +import React from 'react' + +import { route } from '@audius/common/utils' + +import { HomePageIcon } from 'pages/home-page/icon' + +import { LeftNavLink } from '../LeftNavLink' +import { NavSpeakerIcon } from '../NavSpeakerIcon' +import { useNavSourcePlayingStatus } from '../useNavSourcePlayingStatus' + +const { HOMEPAGE_PAGE } = route + +export const HomeNavItem = () => { + const playingFromRoute = useNavSourcePlayingStatus() + + return ( + + } + > + Home + + ) +} 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 f107bfb092a..943561a6264 100644 --- a/packages/web/src/components/nav/desktop/nav-items/index.ts +++ b/packages/web/src/components/nav/desktop/nav-items/index.ts @@ -1,3 +1,4 @@ +export { HomeNavItem } from './HomeNavItem' export { FeedNavItem } from './FeedNavItem' export { TrendingNavItem } from './TrendingNavItem' export { ExploreNavItem } from './ExploreNavItem' diff --git a/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx b/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx index a0f3858a779..a5805686047 100644 --- a/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx +++ b/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx @@ -1,27 +1,33 @@ import { useIsAccountLoaded } from '@audius/common/api' import { useOrderedCompletionStages } from '@audius/common/src/store/challenges' import { challengesSelectors, profilePageActions } from '@audius/common/store' -import { Box, Flex, Text, useTheme } from '@audius/harmony' +import { Box, Flex, Text, TextLink, useTheme } from '@audius/harmony' import { useDispatch, useSelector } from 'react-redux' // eslint-disable-next-line no-restricted-imports -- TODO: migrate to @react-spring/web import { useSpring, animated } from 'react-spring' import { SegmentedProgressBar } from 'components/segmented-progress-bar/SegmentedProgressBar' -import { useProfileCompletionDismissal, useVerticalCollapse } from '../hooks' +import { useProfileCompletionDismissal } from '../hooks' -import { TaskCompletionList } from './TaskCompletionList' +import { TaskCompletionItem } from './TaskCompletionItem' const animatedAny = animated as any const { profileMeterDismissed } = profilePageActions const { getProfilePageMeterDismissed } = challengesSelectors const messages = { - complete: 'Profile Complete' + complete: 'Profile Complete', + dismiss: 'Dismiss' } -const ORIGINAL_HEIGHT_PIXELS = 206 -const CARD_HEIGHT_PIXELS = 182 +const BADGE_COLUMN_WIDTH = 280 + +// Container-query stacking: when the card is below this width, the layout +// collapses to a single stacked column. +const CARD_CONTAINER_NAME = 'profile-completion-hero-card' +const STACK_BREAKPOINT_PX = 600 +const STACK_QUERY = `@container ${CARD_CONTAINER_NAME} (max-width: ${STACK_BREAKPOINT_PX}px)` interface CompletionStage { isCompleted: boolean @@ -38,21 +44,53 @@ export const getPercentageComplete = ( return (stepsCompleted / completionStages.length) * 100 } +const sortIncompleteFirst = (list: CompletionStage[]) => { + const incomplete = list.filter((e) => !e.isCompleted) + const complete = list.filter((e) => e.isCompleted) + return [...incomplete, ...complete] +} + +type ProfileCompletionHeroCardProps = { + isDismissed?: boolean + onDismiss?: () => void + /** + * When true, bypasses the dismissal/auto-hide gates and forces the meter + * visible. Intended for testing/QA. + */ + forceVisible?: boolean +} + /** - * ProfileCompletionHeroCard is the hero card that shows the profile completion percentage, - * the progress meter, and the list of completed stages. It handles its own state management - * and animations. + * ProfileCompletionHeroCard is the larger profile completion meter shown on + * surfaces with more horizontal space (e.g. /home and the profile page). + * + * Layout: badge (percentage + bar) on the left and task grid on the right at + * wide card widths; stacks vertically below `STACK_BREAKPOINT_PX`. Stacking + * is driven by container queries on the card itself, not viewport, so the + * card behaves correctly inside any-width container. + * + * The task grid uses CSS `repeat(auto-fit, minmax(...))` so columns reflow + * continuously as the card narrows — at every intermediate width tasks fit + * cleanly into however many columns work. + * + * Dismiss lives in a footer row (no absolute positioning) so the layout has + * no hidden overflow risk. + * + * Pass `isDismissed`/`onDismiss` to override the default profile-page-scoped + * dismissal state (e.g. for use on /home). */ -export const ProfileCompletionHeroCard = () => { +export const ProfileCompletionHeroCard = ( + props: ProfileCompletionHeroCardProps = {} +) => { const dispatch = useDispatch() + const theme = useTheme() const isAccountLoaded = useIsAccountLoaded() const completionStages = useOrderedCompletionStages() - const isDismissed = useSelector(getProfilePageMeterDismissed) - const theme = useTheme() - const { color } = theme + const reduxIsDismissed = useSelector(getProfilePageMeterDismissed) - const onDismiss = () => dispatch(profileMeterDismissed()) + const isDismissed = props.isDismissed ?? reduxIsDismissed + const onDismiss = props.onDismiss ?? (() => dispatch(profileMeterDismissed())) const { isHidden, shouldNeverShow } = useProfileCompletionDismissal({ onDismiss, @@ -61,7 +99,8 @@ export const ProfileCompletionHeroCard = () => { isDismissed }) - const transitions = useVerticalCollapse(!isHidden, ORIGINAL_HEIGHT_PIXELS) + const effectiveIsHidden = props.forceVisible ? false : isHidden + const effectiveShouldNeverShow = props.forceVisible ? false : shouldNeverShow const stepsCompleted = getStepsCompleted(completionStages) const percentageCompleted = getPercentageComplete(completionStages) @@ -70,95 +109,121 @@ export const ProfileCompletionHeroCard = () => { from: { animatedPercentage: 0 } }) - if (shouldNeverShow) return null + if (effectiveShouldNeverShow || effectiveIsHidden) return null + + const sortedStages = sortIncompleteFirst(completionStages) return ( - <> - {transitions.map(({ item, key, props }) => - item ? ( - - - - - - {animatedPercentage.interpolate((v: unknown) => - (v as number).toFixed() - )} - - % - - - - {messages.complete} - - - - - - - - - - - ) : null - )} - + + + + + + {animatedPercentage.interpolate((v: unknown) => + (v as number).toFixed() + )} + + % + + + {messages.complete} + + + + + + {sortedStages.map((stage) => ( + + ))} + + + + {messages.dismiss} + + + + + ) } diff --git a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx index fe20be9cf18..9aff21dfe38 100644 --- a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx +++ b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx @@ -49,6 +49,7 @@ export const TaskCompletionItem = ({ borderRadius={variant === 'surface' ? 's' : undefined} pv={variant === 'surface' ? 's' : undefined} ph={variant === 'surface' ? 'm' : undefined} + css={{ minWidth: 0 }} > {title} diff --git a/packages/web/src/pages/home-page/HomePage.tsx b/packages/web/src/pages/home-page/HomePage.tsx new file mode 100644 index 00000000000..dbb6ddbf51f --- /dev/null +++ b/packages/web/src/pages/home-page/HomePage.tsx @@ -0,0 +1,24 @@ +import { useIsMobile } from 'hooks/useIsMobile' + +import { DesktopHomePage } from './components/desktop/HomePage' +import { MobileHomePage } from './components/mobile/HomePage' + +const messages = { + title: 'Home', + pageTitle: 'Your home on Audius', + description: 'Your personalized home on Audius' +} + +export const HomePage = () => { + const isMobile = useIsMobile() + const Component = isMobile ? MobileHomePage : DesktopHomePage + return ( + + ) +} + +export default HomePage diff --git a/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx b/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx new file mode 100644 index 00000000000..693c693db1a --- /dev/null +++ b/packages/web/src/pages/home-page/components/ActiveContestsStrip.tsx @@ -0,0 +1,83 @@ +import { useMemo } from 'react' + +import { + useAllRemixContests, + useCurrentUserId, + useUserRemixContests +} from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Box } from '@audius/harmony' +import { + GetContestsByUserStatusEnum, + GetRemixContestsStatusEnum +} from '@audius/sdk' + +import { ContestCard, ContestCardSkeleton } from 'components/contest-card' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' +import { CONTEST_CARD_WIDTH } from '../../search-explore-page/components/desktop/constants' + +const SKELETON_COUNT = 6 +const MAX_TILES = 12 + +const messages = { + title: 'Active Contests', + empty: 'No active contests right now' +} + +export const ActiveContestsStrip = () => { + const { data: currentUserId } = useCurrentUserId() + + const { + data: allActiveTrackIds, + isPending: isAllPending, + isError: isAllError + } = useAllRemixContests({ + status: GetRemixContestsStatusEnum.Active + }) + + const { data: hostedTrackIds, isPending: isHostedPending } = + useUserRemixContests( + { + userId: currentUserId, + status: GetContestsByUserStatusEnum.Active + }, + { enabled: !!currentUserId } + ) + + const ordered = useMemo(() => { + const all = allActiveTrackIds ?? [] + const hosted = new Set(hostedTrackIds ?? []) + if (hosted.size === 0) return all.slice(0, MAX_TILES) + const top: number[] = [] + const rest: number[] = [] + for (const id of all) { + if (hosted.has(id)) top.push(id) + else rest.push(id) + } + return [...top, ...rest].slice(0, MAX_TILES) + }, [allActiveTrackIds, hostedTrackIds]) + + if (isAllError) return null + + const isLoading = + isAllPending || (!!currentUserId && isHostedPending && !hostedTrackIds) + + if (!isLoading && ordered.length === 0) return null + + return ( + + {isLoading + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + + + )) + : ordered.map((trackId) => ( + + + + ))} + + ) +} diff --git a/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx b/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx new file mode 100644 index 00000000000..7bd17db852e --- /dev/null +++ b/packages/web/src/pages/home-page/components/FromPeopleYouFollowSection.tsx @@ -0,0 +1,45 @@ +import { useFeed } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { EntityType } from '@audius/sdk' + +import { CollectionCard } from 'components/collection' +import { TrackCard, TrackCardSkeleton } from 'components/track/TrackCard' +import { useIsMobile } from 'hooks/useIsMobile' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' + +const PAGE_SIZE = 10 +const SKELETON_COUNT = 6 + +const messages = { + title: 'Recent from People You Follow' +} + +export const FromPeopleYouFollowSection = () => { + const isMobile = useIsMobile() + const { data, isLoading, isError, isSuccess } = useFeed({ + initialPageSize: PAGE_SIZE + }) + + if (isError || (isSuccess && !data?.length)) { + return null + } + + return ( + + {isLoading || !data + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : data + .slice(0, PAGE_SIZE) + .map(({ id, type }) => + type === EntityType.TRACK ? ( + + ) : ( + + ) + )} + + ) +} diff --git a/packages/web/src/pages/home-page/components/QuickLinks.tsx b/packages/web/src/pages/home-page/components/QuickLinks.tsx new file mode 100644 index 00000000000..e95a84b4f90 --- /dev/null +++ b/packages/web/src/pages/home-page/components/QuickLinks.tsx @@ -0,0 +1,278 @@ +import { useMemo } from 'react' + +import { + useArtistCreatedFanClub, + useCurrentAccountUser, + useCurrentUserId, + useTrack, + useUserRemixContests +} from '@audius/common/api' +import { useChallengeCooldownSchedule } from '@audius/common/hooks' +import { formatNumberCommas, route } from '@audius/common/utils' +import { + Flex, + IconCloudUpload, + IconDiscord, + IconQuestionCircle, + IconStar, + IconTokenAUDIO, + IconTrophy, + IconUser, + IconVerified, + Paper, + Text, + useTheme +} from '@audius/harmony' +import { GetContestsByUserStatusEnum } from '@audius/sdk' +import { useNavigate } from 'react-router' + +const { + AUDIUS_DISCORD_LINK, + AUDIUS_HELP_LINK, + CHECK_PAGE, + CLUBS_CREATE_PAGE, + CONTESTS_PAGE, + HOST_REMIX_CONTEST_ROOT_PAGE, + REWARDS_PAGE, + SIGN_UP_PAGE, + UPLOAD_PAGE, + clubPage +} = route + +const messages = { + hostContest: 'Host a Contest', + manageContest: 'Manage Contest', + manageMultipleContests: 'Manage Contests', + launchFanClub: 'Launch a Fan Club', + manageFanClub: 'Manage Fan Club', + uploadTrack: 'Upload', + getVerified: 'Get Verified', + joinDiscord: 'Discord', + support: 'Support', + signUp: 'Sign Up', + rewardsLabel: '$AUDIO' +} + +type Pill = { + key: string + label: string + icon: React.ComponentType + href?: string + to?: string + external?: boolean + highlight?: boolean +} + +const PillItem = ({ pill }: { pill: Pill }) => { + const navigate = useNavigate() + const { color } = useTheme() + const onClick = () => { + if (pill.external && pill.href) { + window.open(pill.href, '_blank', 'noopener,noreferrer') + return + } + if (pill.to) navigate(pill.to) + } + return ( + + + + {pill.label} + + + ) +} + +type QuickLinksProps = { + /** + * When true, surfaces a "Rewards" pill in the row showing claimable $AUDIO. + * Desktop omits this because RewardsSummaryCard already covers it. + */ + showRewardsPill?: boolean +} + +export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { + const { data: currentUserId } = useCurrentUserId() + const { data: currentUser } = useCurrentAccountUser() + const isAuthed = !!currentUserId + + const { claimableAmount, isEmpty: isRewardsEmpty } = + useChallengeCooldownSchedule({ multiple: true }) + + const { data: hostedContestTrackIds } = useUserRemixContests( + { + userId: currentUserId, + status: GetContestsByUserStatusEnum.Active + }, + { enabled: isAuthed } + ) + const singleHostedTrackId = + hostedContestTrackIds?.length === 1 ? hostedContestTrackIds[0] : null + const { data: hostedTrack } = useTrack(singleHostedTrackId ?? undefined) + + const { data: createdFanClub } = useArtistCreatedFanClub(currentUserId, { + enabled: isAuthed + }) + + const isVerified = !!currentUser?.is_verified + + const pills = useMemo(() => { + if (!isAuthed) { + return [ + { + key: 'discord', + label: messages.joinDiscord, + icon: IconDiscord, + href: AUDIUS_DISCORD_LINK, + external: true + }, + { + key: 'support', + label: messages.support, + icon: IconQuestionCircle, + href: AUDIUS_HELP_LINK, + external: true + }, + { + key: 'signup', + label: messages.signUp, + icon: IconUser, + to: SIGN_UP_PAGE + } + ] + } + + const items: Pill[] = [] + + if (showRewardsPill && !isRewardsEmpty) { + items.push({ + key: 'rewards', + label: `${formatNumberCommas(claimableAmount)} ${messages.rewardsLabel}`, + icon: IconTokenAUDIO, + to: REWARDS_PAGE, + highlight: true + }) + } + + const hostedCount = hostedContestTrackIds?.length ?? 0 + if (hostedCount === 0) { + items.push({ + key: 'host-contest', + label: messages.hostContest, + icon: IconTrophy, + to: HOST_REMIX_CONTEST_ROOT_PAGE + }) + } else if (hostedCount === 1 && hostedTrack?.permalink) { + items.push({ + key: 'manage-contest', + label: messages.manageContest, + icon: IconTrophy, + to: hostedTrack.permalink + }) + } else { + items.push({ + key: 'manage-contests', + label: messages.manageMultipleContests, + icon: IconTrophy, + to: CONTESTS_PAGE + }) + } + + if (createdFanClub?.ticker) { + items.push({ + key: 'manage-fan-club', + label: messages.manageFanClub, + icon: IconStar, + to: clubPage(createdFanClub.ticker) + }) + } else if (isVerified) { + items.push({ + key: 'launch-fan-club', + label: messages.launchFanClub, + icon: IconStar, + to: CLUBS_CREATE_PAGE + }) + } + + items.push({ + key: 'upload', + label: messages.uploadTrack, + icon: IconCloudUpload, + to: UPLOAD_PAGE + }) + + if (!isVerified) { + items.push({ + key: 'verify', + label: messages.getVerified, + icon: IconVerified, + to: CHECK_PAGE + }) + } + + items.push( + { + key: 'discord', + label: messages.joinDiscord, + icon: IconDiscord, + href: AUDIUS_DISCORD_LINK, + external: true + }, + { + key: 'support', + label: messages.support, + icon: IconQuestionCircle, + href: AUDIUS_HELP_LINK, + external: true + } + ) + + return items + }, [ + isAuthed, + showRewardsPill, + isRewardsEmpty, + claimableAmount, + hostedContestTrackIds, + hostedTrack, + createdFanClub, + isVerified + ]) + + return ( + + {pills.map((pill) => ( + + ))} + + ) +} diff --git a/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx new file mode 100644 index 00000000000..2466cb4510e --- /dev/null +++ b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx @@ -0,0 +1,79 @@ +import { useCallback } from 'react' + +import { useChallengeCooldownSchedule } from '@audius/common/hooks' +import { route, formatNumberCommas } from '@audius/common/utils' +import { + Button, + Flex, + IconArrowRight, + Paper, + PlainButton, + Text +} from '@audius/harmony' +import { useNavigate } from 'react-router' + +import { useModalState } from 'common/hooks/useModalState' + +const { REWARDS_PAGE } = route + +const messages = { + yourRewards: 'Your Rewards', + readyToClaim: 'Ready to Claim', + claimAll: 'Claim All', + viewAll: 'View All Rewards' +} + +export const RewardsSummaryCard = () => { + const { claimableAmount, isEmpty } = useChallengeCooldownSchedule({ + multiple: true + }) + const [, setClaimAllRewardsVisibility] = useModalState('ClaimAllRewards') + const navigate = useNavigate() + + const onClickClaim = useCallback(() => { + setClaimAllRewardsVisibility(true) + }, [setClaimAllRewardsVisibility]) + + const onClickViewAll = useCallback(() => { + navigate(REWARDS_PAGE) + }, [navigate]) + + if (isEmpty) return null + + return ( + + + + {messages.yourRewards} + + + {messages.viewAll} + + + + + + + {formatNumberCommas(claimableAmount)} + + + $AUDIO + + + + {messages.readyToClaim} + + + {claimableAmount > 0 ? ( + + ) : null} + + + ) +} diff --git a/packages/web/src/pages/home-page/components/StatusZone.tsx b/packages/web/src/pages/home-page/components/StatusZone.tsx new file mode 100644 index 00000000000..c1a4e701b8e --- /dev/null +++ b/packages/web/src/pages/home-page/components/StatusZone.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from 'react' + +import { useCurrentUserId } from '@audius/common/api' +import { Flex } from '@audius/harmony' + +import { ProfileCompletionHeroCard } from 'components/profile-progress/components/ProfileCompletionHeroCard' + +import { QuickLinks } from './QuickLinks' +import { RewardsSummaryCard } from './RewardsSummaryCard' + +const dismissalKey = (userId: number | null | undefined) => + userId ? `audius-home-profile-meter-dismissed-${userId}` : null + +const useHomeProfileMeterDismissal = () => { + const { data: currentUserId } = useCurrentUserId() + const key = dismissalKey(currentUserId) + const [isDismissed, setIsDismissed] = useState(false) + + useEffect(() => { + if (!key) { + setIsDismissed(false) + return + } + try { + setIsDismissed(window.localStorage.getItem(key) === 'true') + } catch { + setIsDismissed(false) + } + }, [key]) + + const onDismiss = useCallback(() => { + if (!key) return + try { + window.localStorage.setItem(key, 'true') + } catch { + // localStorage may be unavailable; fall back to in-memory state + } + setIsDismissed(true) + }, [key]) + + return { isDismissed, onDismiss } +} + +type StatusZoneProps = { + variant: 'desktop' | 'mobile' +} + +export const StatusZone = ({ variant }: StatusZoneProps) => { + const { isDismissed, onDismiss } = useHomeProfileMeterDismissal() + + if (variant === 'mobile') { + // Mobile: skip RewardsSummaryCard + ProfileCompletionHeroCard; rewards + // surfaces as a pill in QuickLinks instead. + return ( + + + + ) + } + + return ( + + + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/UnauthHero.tsx b/packages/web/src/pages/home-page/components/UnauthHero.tsx new file mode 100644 index 00000000000..cb6c26982d8 --- /dev/null +++ b/packages/web/src/pages/home-page/components/UnauthHero.tsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react' + +import { route } from '@audius/common/utils' +import { Button, Flex, IconArrowRight, Paper, Text } from '@audius/harmony' +import { useNavigate } from 'react-router' + +const { SIGN_UP_PAGE } = route + +const messages = { + title: 'Welcome to Audius', + subtitle: + 'Discover new music, support artists directly, and join a community owned by its creators.', + signUp: 'Sign Up Free' +} + +export const UnauthHero = () => { + const navigate = useNavigate() + const onSignUp = useCallback(() => { + navigate(SIGN_UP_PAGE) + }, [navigate]) + + return ( + + + + {messages.title} + + + {messages.subtitle} + + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx b/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx new file mode 100644 index 00000000000..9ba5405b4e7 --- /dev/null +++ b/packages/web/src/pages/home-page/components/YourTopArtistsSection.tsx @@ -0,0 +1,146 @@ +import { useMemo } from 'react' + +import { useTracks, useLibraryTracks } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { + GetUserLibraryTracksSortDirectionEnum, + GetUserLibraryTracksSortMethodEnum, + GetUserLibraryTracksTypeEnum +} from '@audius/sdk' + +import { UserCard, UserCardSkeleton } from 'components/user-card' +import { useIsMobile } from 'hooks/useIsMobile' + +import { Carousel } from '../../search-explore-page/components/desktop/Carousel' + +const PAGE_SIZE = 50 +const TOP_N = 12 +const NINETY_DAYS_MS = 1000 * 60 * 60 * 24 * 90 +const FAVORITE_WEIGHT = 1.0 +const REPOST_WEIGHT = 1.0 +const BOTH_BOOST = 1.5 + +const messages = { + title: 'Your Top Artists' +} + +type LibraryItem = { + id: ID + type: string + timestamp?: string +} + +const filterRecent = (items: LibraryItem[], cutoffMs: number) => { + return items.filter((item) => { + if (!item.timestamp) return false + const t = new Date(item.timestamp).getTime() + if (Number.isNaN(t)) return false + return t >= cutoffMs + }) +} + +export const YourTopArtistsSection = () => { + const isMobile = useIsMobile() + + const { + data: favoriteData, + isLoading: isFavoritesLoading, + isError: isFavoritesError + } = useLibraryTracks({ + category: GetUserLibraryTracksTypeEnum.Favorite, + sortMethod: GetUserLibraryTracksSortMethodEnum.AddedDate, + sortDirection: GetUserLibraryTracksSortDirectionEnum.Desc, + pageSize: PAGE_SIZE + }) + + const { + data: repostData, + isLoading: isRepostsLoading, + isError: isRepostsError + } = useLibraryTracks({ + category: GetUserLibraryTracksTypeEnum.Repost, + sortMethod: GetUserLibraryTracksSortMethodEnum.AddedDate, + sortDirection: GetUserLibraryTracksSortDirectionEnum.Desc, + pageSize: PAGE_SIZE + }) + + const allIds = useMemo(() => { + const set = new Set() + favoriteData?.forEach((d) => set.add(d.id as ID)) + repostData?.forEach((d) => set.add(d.id as ID)) + return Array.from(set) + }, [favoriteData, repostData]) + + const { byId: trackById, isLoading: isTracksLoading } = useTracks(allIds) + + const topArtistIds = useMemo(() => { + if (!favoriteData || !repostData) return [] + const cutoff = Date.now() - NINETY_DAYS_MS + + const recentFavorites = filterRecent(favoriteData as LibraryItem[], cutoff) + const recentReposts = filterRecent(repostData as LibraryItem[], cutoff) + + type Bucket = { score: number; latestTs: number } + const perArtist = new Map() + const seenTrackOwner = new Map() + + const addAction = (trackId: ID, ts: number, weight: number) => { + const track = trackById[trackId] + if (!track) return + const ownerId = track.owner_id + if (!ownerId) return + + // Track-level dedupe: if same track has been seen, boost weight + const existing = seenTrackOwner.get(trackId) + if (existing) { + // Both favorite and repost present — replace contribution with boosted weight + const bucket = perArtist.get(ownerId) + if (bucket) { + bucket.score = bucket.score - existing.weight + BOTH_BOOST + bucket.latestTs = Math.max(bucket.latestTs, Math.min(existing.ts, ts)) + } + seenTrackOwner.set(trackId, { weight: BOTH_BOOST, ts }) + return + } + + seenTrackOwner.set(trackId, { weight, ts }) + const bucket = perArtist.get(ownerId) + if (bucket) { + bucket.score += weight + bucket.latestTs = Math.max(bucket.latestTs, ts) + } else { + perArtist.set(ownerId, { score: weight, latestTs: ts }) + } + } + + recentFavorites.forEach((item) => { + const ts = item.timestamp ? new Date(item.timestamp).getTime() : 0 + addAction(item.id, ts, FAVORITE_WEIGHT) + }) + recentReposts.forEach((item) => { + const ts = item.timestamp ? new Date(item.timestamp).getTime() : 0 + addAction(item.id, ts, REPOST_WEIGHT) + }) + + return Array.from(perArtist.entries()) + .sort((a, b) => b[1].score - a[1].score) + .slice(0, TOP_N) + .map(([artistId]) => artistId) + }, [favoriteData, repostData, trackById]) + + if (isFavoritesError || isRepostsError) return null + + const isLoading = isFavoritesLoading || isRepostsLoading || isTracksLoading + + if (!isLoading && topArtistIds.length === 0) return null + + return ( + + {isLoading + ? Array.from({ length: 6 }).map((_, i) => ( + + )) + : topArtistIds.map((id) => )} + + ) +} diff --git a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx new file mode 100644 index 00000000000..12d2b830412 --- /dev/null +++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx @@ -0,0 +1,146 @@ +import { Fragment, ReactNode, useCallback, useMemo } from 'react' + +import { useCurrentUserId } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Flex } from '@audius/harmony' +import type { Mood } from '@audius/sdk' +import { useNavigate } from 'react-router' + +import { MIN_DESKTOP_CONTENT_WIDTH_PX } from 'common/utils/layout' +import { Header } from 'components/header/desktop/Header' +import Page from 'components/page/Page' + +import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' +import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' +import { MoodGrid } from '../../../search-explore-page/components/desktop/MoodGrid' +import { RecentlyPlayedSection } from '../../../search-explore-page/components/desktop/RecentlyPlayedSection' +import { RecommendedTracksSection } from '../../../search-explore-page/components/desktop/RecommendedTracksSection' +import { UndergroundTrendingTracksSection } from '../../../search-explore-page/components/desktop/UndergroundTrendingTracksSection' +import { HomePageIcon } from '../../icon' +import { ActiveContestsStrip } from '../ActiveContestsStrip' +import { FromPeopleYouFollowSection } from '../FromPeopleYouFollowSection' +import { StatusZone } from '../StatusZone' +import { UnauthHero } from '../UnauthHero' +import { YourTopArtistsSection } from '../YourTopArtistsSection' + +const messages = { + title: 'Home' +} + +export type DesktopHomePageProps = { + title: string + pageTitle: string + description: string +} + +export const DesktopHomePage = ({ + pageTitle, + description +}: DesktopHomePageProps) => { + const navigate = useNavigate() + const { data: currentUserId, isPending: isCurrentUserIdLoading } = + useCurrentUserId() + const showUserContextualContent = isCurrentUserIdLoading || !!currentUserId + + const onMoodClick = useCallback( + (mood: Mood) => { + navigate(route.searchPage({ category: 'tracks', mood })) + }, + [navigate] + ) + + const sectionConfigs = useMemo< + { key: string; shouldRender: boolean; element: ReactNode }[] + >( + () => [ + { + key: 'unauth-hero', + shouldRender: !showUserContextualContent, + element: + }, + { + key: 'status-zone', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recommended-tracks', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recently-played', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'active-contests', + shouldRender: true, + element: + }, + { + key: 'featured-playlists', + shouldRender: true, + element: + }, + { + key: 'top-artists', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'from-follows', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'underground-trending', + shouldRender: true, + element: + }, + { + key: 'mood-grid', + shouldRender: true, + element: + }, + { + key: 'artist-spotlight', + shouldRender: !showUserContextualContent, + element: + } + ], + [showUserContextualContent, onMoodClick] + ) + + const header =
+ + return ( + + + + {sectionConfigs.map(({ key, shouldRender, element }) => + shouldRender ? {element} : null + )} + + + + ) +} diff --git a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx new file mode 100644 index 00000000000..03d8bb5239d --- /dev/null +++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx @@ -0,0 +1,137 @@ +import { + Fragment, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo +} from 'react' + +import { useCurrentUserId } from '@audius/common/api' +import { route } from '@audius/common/utils' +import { Flex } from '@audius/harmony' +import type { Mood } from '@audius/sdk' +import { useNavigate } from 'react-router' + +import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' +import NavContext, { CenterPreset } from 'components/nav/mobile/NavContext' + +import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' +import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' +import { MoodGrid } from '../../../search-explore-page/components/desktop/MoodGrid' +import { RecentlyPlayedSection } from '../../../search-explore-page/components/desktop/RecentlyPlayedSection' +import { RecommendedTracksSection } from '../../../search-explore-page/components/desktop/RecommendedTracksSection' +import { UndergroundTrendingTracksSection } from '../../../search-explore-page/components/desktop/UndergroundTrendingTracksSection' +import { ActiveContestsStrip } from '../ActiveContestsStrip' +import { FromPeopleYouFollowSection } from '../FromPeopleYouFollowSection' +import { StatusZone } from '../StatusZone' +import { UnauthHero } from '../UnauthHero' +import { YourTopArtistsSection } from '../YourTopArtistsSection' + +const messages = { + title: 'Home' +} + +export type MobileHomePageProps = { + title: string + pageTitle: string + description: string +} + +export const MobileHomePage = (_props: MobileHomePageProps) => { + const navigate = useNavigate() + const { data: currentUserId, isPending: isCurrentUserIdLoading } = + useCurrentUserId() + const showUserContextualContent = isCurrentUserIdLoading || !!currentUserId + + const { setCenter, setRight } = useContext(NavContext)! + + useEffect(() => { + setRight(null) + setCenter(CenterPreset.LOGO) + }, [setCenter, setRight]) + + const onMoodClick = useCallback( + (mood: Mood) => { + navigate(route.searchPage({ category: 'tracks', mood })) + }, + [navigate] + ) + + const sectionConfigs = useMemo< + { key: string; shouldRender: boolean; element: ReactNode }[] + >( + () => [ + { + key: 'unauth-hero', + shouldRender: !showUserContextualContent, + element: + }, + { + key: 'status-zone', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recommended-tracks', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'recently-played', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'active-contests', + shouldRender: true, + element: + }, + { + key: 'featured-playlists', + shouldRender: true, + element: + }, + { + key: 'top-artists', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'from-follows', + shouldRender: showUserContextualContent, + element: + }, + { + key: 'underground-trending', + shouldRender: true, + element: + }, + { + key: 'mood-grid', + shouldRender: true, + element: + }, + { + key: 'artist-spotlight', + shouldRender: !showUserContextualContent, + element: + } + ], + [showUserContextualContent, onMoodClick] + ) + + return ( + + + {sectionConfigs.map(({ key, shouldRender, element }) => + shouldRender ? {element} : null + )} + + + ) +} diff --git a/packages/web/src/pages/home-page/icon.ts b/packages/web/src/pages/home-page/icon.ts new file mode 100644 index 00000000000..86459e99e96 --- /dev/null +++ b/packages/web/src/pages/home-page/icon.ts @@ -0,0 +1,3 @@ +import { IconHome } from '@audius/harmony' + +export const HomePageIcon = IconHome diff --git a/packages/web/src/pages/home-page/index.ts b/packages/web/src/pages/home-page/index.ts new file mode 100644 index 00000000000..e58765bab49 --- /dev/null +++ b/packages/web/src/pages/home-page/index.ts @@ -0,0 +1,2 @@ +export { HomePage as default } from './HomePage' +export { HomePage } from './HomePage' diff --git a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx index b96970d0177..bbbe75cac25 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx @@ -49,7 +49,6 @@ import NavBanner, { EmptyNavBanner } from 'components/nav-banner/NavBanner' import { FlushPageContainer } from 'components/page/FlushPageContainer' import Page from 'components/page/Page' import ProfilePicture from 'components/profile-picture/ProfilePicture' -import { ProfileCompletionHeroCard } from 'components/profile-progress/components/ProfileCompletionHeroCard' import { EmptyStatBanner, StatBanner } from 'components/stat-banner/StatBanner' import { Tab, TabList } from 'components/tabs' import UploadChip from 'components/upload/UploadChip' @@ -168,9 +167,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { onCloseUnblockUserConfirmationModal, onCloseMuteUserConfirmationModal } = useProfilePage() - const renderProfileCompletionCard = () => { - return isOwner ? : null - } const isDeactivated = !!profile?.is_deactivated @@ -321,7 +317,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // Default: Tracks return ( - {renderProfileCompletionCard()} {status === Status.SUCCESS ? ( tracksEmpty ? ( <> @@ -372,7 +367,6 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // Default: Reposts return ( - {renderProfileCompletionCard()} {userRepostsEmpty ? ( { +type MoodGridProps = { + onMoodClick?: (mood: Mood) => void +} + +export const MoodGrid = ({ onMoodClick }: MoodGridProps = {}) => { const [category, setCategory] = useSearchCategory() const { color } = useTheme() @@ -17,13 +21,17 @@ export const MoodGrid = () => { const handleMoodPress = useCallback( (mood: Mood) => { + if (onMoodClick) { + onMoodClick(mood) + return + } if (category === 'all') { setCategory('tracks', { mood }) } else { setCategory(category, { mood }) } }, - [category, setCategory] + [category, setCategory, onMoodClick] ) return ( diff --git a/packages/web/src/ssr/util.ts b/packages/web/src/ssr/util.ts index 8336fc62394..f1cc00b9363 100644 --- a/packages/web/src/ssr/util.ts +++ b/packages/web/src/ssr/util.ts @@ -13,6 +13,7 @@ const invalidPaths = new Set(['undefined']) // Reserved paths that have their own SSR handlers and should NOT match /@handle patterns // This prevents /upload from being matched as a profile with handle="upload" const reservedPaths = new Set([ + 'home', 'upload', 'explore', 'audio', @@ -36,7 +37,7 @@ const reservedPaths = new Set([ ]) // Static routes that should skip SSR (only the root now, all others have SSR handlers) -const staticRoutes = new Set(['/']) +const staticRoutes = new Set(['/', '/home']) // Paths that should not use SSR even if they match a route const nonSsrPaths = [ diff --git a/packages/web/src/store/lineup/lineupForRoute.js b/packages/web/src/store/lineup/lineupForRoute.js index ba9694ce104..51ddbe0f7c3 100644 --- a/packages/web/src/store/lineup/lineupForRoute.js +++ b/packages/web/src/store/lineup/lineupForRoute.js @@ -27,7 +27,8 @@ const { SETTINGS_PAGE, NOT_FOUND_PAGE, LIBRARY_PAGE, - TRACK_EDIT_PAGE + TRACK_EDIT_PAGE, + HOMEPAGE_PAGE } = route const { getCollectionTracksLineup } = collectionPageSelectors const { getDiscoverFeedLineup } = feedPageSelectors @@ -50,7 +51,8 @@ export const getLineupSelectorForRoute = (location) => { matchPage(UPLOAD_PAGE) || matchPage(DASHBOARD_PAGE) || matchPage(SETTINGS_PAGE) || - matchPage(NOT_FOUND_PAGE) + matchPage(NOT_FOUND_PAGE) || + matchPage(HOMEPAGE_PAGE) ) { return () => null } From 0d522b4f3c40b3a30daafdb1a5bee5cd304b543c Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 8 May 2026 14:56:16 -0500 Subject: [PATCH 2/9] Refine home and profile completion layouts --- .../components/ProfileCompletionHeroCard.tsx | 15 +++++- .../components/TaskCompletionItem.tsx | 7 +-- .../components/desktop/FeedPageContent.tsx | 2 +- .../components/mobile/FeedPageContent.tsx | 2 +- .../pages/home-page/components/QuickLinks.tsx | 50 +++++++++++++------ .../pages/home-page/components/StatusZone.tsx | 1 - .../home-page/components/desktop/HomePage.tsx | 11 ++-- .../home-page/components/mobile/HomePage.tsx | 11 ++-- 8 files changed, 65 insertions(+), 34 deletions(-) diff --git a/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx b/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx index a5805686047..4b6e61d4965 100644 --- a/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx +++ b/packages/web/src/components/profile-progress/components/ProfileCompletionHeroCard.tsx @@ -28,6 +28,11 @@ const BADGE_COLUMN_WIDTH = 280 const CARD_CONTAINER_NAME = 'profile-completion-hero-card' const STACK_BREAKPOINT_PX = 600 const STACK_QUERY = `@container ${CARD_CONTAINER_NAME} (max-width: ${STACK_BREAKPOINT_PX}px)` +// At and above this card width, the task grid switches to two columns. Below +// this (but still in the side-by-side badge layout), the task grid is a +// single column so the longest task titles never need to truncate. +const TWO_COL_BREAKPOINT_PX = 800 +const TWO_COL_QUERY = `@container ${CARD_CONTAINER_NAME} (min-width: ${TWO_COL_BREAKPOINT_PX}px)` interface CompletionStage { isCompleted: boolean @@ -199,11 +204,17 @@ export const ProfileCompletionHeroCard = ( css={{ width: '100%', display: 'grid', - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + // Single column by default — fits any task title without any + // truncation, and looks consistent with the sidebar tooltip + // pattern. At wide card widths we promote to two columns so + // the meter doesn't waste vertical space. + gridTemplateColumns: 'minmax(0, 1fr)', gap: theme.spacing.s, alignContent: 'start', + [TWO_COL_QUERY]: { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' + }, [STACK_QUERY]: { - gridTemplateColumns: 'minmax(0, 1fr)', gap: theme.spacing.xs } }} diff --git a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx index 9aff21dfe38..d22594302ea 100644 --- a/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx +++ b/packages/web/src/components/profile-progress/components/TaskCompletionItem.tsx @@ -49,7 +49,7 @@ export const TaskCompletionItem = ({ borderRadius={variant === 'surface' ? 's' : undefined} pv={variant === 'surface' ? 's' : undefined} ph={variant === 'surface' ? 'm' : undefined} - css={{ minWidth: 0 }} + css={{ flexShrink: 0, flexWrap: 'nowrap' }} > {title} diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx index 9cb5a2356e6..ad6f7abb9b6 100644 --- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx @@ -28,7 +28,7 @@ import EmptyFeed from 'pages/feed-page/components/EmptyFeed' import { FeedFilters } from './FeedFilters' const messages = { - feedHeaderTitle: 'Your Feed', + feedHeaderTitle: 'Feed', feedTitle: 'Feed', feedDescription: 'Listen to what people you follow are sharing' } diff --git a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx index fef08575c10..e5dee15ed8c 100644 --- a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx @@ -34,7 +34,7 @@ import styles from './FeedPageContent.module.css' const { FEED_PAGE } = route const messages = { - title: 'Your Feed', + title: 'Feed', feedTitle: 'Feed', feedDescription: 'Listen to what people you follow are sharing' } diff --git a/packages/web/src/pages/home-page/components/QuickLinks.tsx b/packages/web/src/pages/home-page/components/QuickLinks.tsx index e95a84b4f90..14c7fc20b23 100644 --- a/packages/web/src/pages/home-page/components/QuickLinks.tsx +++ b/packages/web/src/pages/home-page/components/QuickLinks.tsx @@ -7,7 +7,7 @@ import { useTrack, useUserRemixContests } from '@audius/common/api' -import { useChallengeCooldownSchedule } from '@audius/common/hooks' +import { useChallengeCooldownSchedule, useIsArtist } from '@audius/common/hooks' import { formatNumberCommas, route } from '@audius/common/utils' import { Flex, @@ -36,7 +36,8 @@ const { REWARDS_PAGE, SIGN_UP_PAGE, UPLOAD_PAGE, - clubPage + clubPage, + profilePage } = route const messages = { @@ -46,6 +47,7 @@ const messages = { launchFanClub: 'Launch a Fan Club', manageFanClub: 'Manage Fan Club', uploadTrack: 'Upload', + yourProfile: 'Your Profile', getVerified: 'Get Verified', joinDiscord: 'Discord', support: 'Support', @@ -133,7 +135,9 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { enabled: isAuthed }) + const isArtist = useIsArtist({ id: currentUserId ?? undefined }) const isVerified = !!currentUser?.is_verified + const currentUserHandle = currentUser?.handle const pills = useMemo(() => { if (!isAuthed) { @@ -173,14 +177,33 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { }) } - const hostedCount = hostedContestTrackIds?.length ?? 0 - if (hostedCount === 0) { + items.push({ + key: 'upload', + label: messages.uploadTrack, + icon: IconCloudUpload, + to: UPLOAD_PAGE + }) + + if (currentUserHandle) { items.push({ - key: 'host-contest', - label: messages.hostContest, - icon: IconTrophy, - to: HOST_REMIX_CONTEST_ROOT_PAGE + key: 'your-profile', + label: messages.yourProfile, + icon: IconUser, + to: profilePage(currentUserHandle) }) + } + + const hostedCount = hostedContestTrackIds?.length ?? 0 + if (hostedCount === 0) { + // Only artists can host a contest — non-artists never see the empty CTA. + if (isArtist) { + items.push({ + key: 'host-contest', + label: messages.hostContest, + icon: IconTrophy, + to: HOST_REMIX_CONTEST_ROOT_PAGE + }) + } } else if (hostedCount === 1 && hostedTrack?.permalink) { items.push({ key: 'manage-contest', @@ -213,13 +236,6 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { }) } - items.push({ - key: 'upload', - label: messages.uploadTrack, - icon: IconCloudUpload, - to: UPLOAD_PAGE - }) - if (!isVerified) { items.push({ key: 'verify', @@ -255,7 +271,9 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { hostedContestTrackIds, hostedTrack, createdFanClub, - isVerified + isArtist, + isVerified, + currentUserHandle ]) return ( diff --git a/packages/web/src/pages/home-page/components/StatusZone.tsx b/packages/web/src/pages/home-page/components/StatusZone.tsx index c1a4e701b8e..76faf1f8d41 100644 --- a/packages/web/src/pages/home-page/components/StatusZone.tsx +++ b/packages/web/src/pages/home-page/components/StatusZone.tsx @@ -64,7 +64,6 @@ export const StatusZone = ({ variant }: StatusZoneProps) => { diff --git a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx index 12d2b830412..5d3b3240857 100644 --- a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx @@ -1,6 +1,6 @@ import { Fragment, ReactNode, useCallback, useMemo } from 'react' -import { useCurrentUserId } from '@audius/common/api' +import { useCurrentUserId, useIsAccountLoaded } from '@audius/common/api' import { route } from '@audius/common/utils' import { Flex } from '@audius/harmony' import type { Mood } from '@audius/sdk' @@ -38,9 +38,12 @@ export const DesktopHomePage = ({ description }: DesktopHomePageProps) => { const navigate = useNavigate() - const { data: currentUserId, isPending: isCurrentUserIdLoading } = - useCurrentUserId() - const showUserContextualContent = isCurrentUserIdLoading || !!currentUserId + const { data: currentUserId } = useCurrentUserId() + const isAccountLoaded = useIsAccountLoaded() + // While the account is still resolving (e.g. during a manager-mode account + // switch), keep the personalized layout in place rather than flashing the + // unauthenticated view. + const showUserContextualContent = !isAccountLoaded || !!currentUserId const onMoodClick = useCallback( (mood: Mood) => { diff --git a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx index 03d8bb5239d..81a40134ffc 100644 --- a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx @@ -7,7 +7,7 @@ import { useMemo } from 'react' -import { useCurrentUserId } from '@audius/common/api' +import { useCurrentUserId, useIsAccountLoaded } from '@audius/common/api' import { route } from '@audius/common/utils' import { Flex } from '@audius/harmony' import type { Mood } from '@audius/sdk' @@ -40,9 +40,12 @@ export type MobileHomePageProps = { export const MobileHomePage = (_props: MobileHomePageProps) => { const navigate = useNavigate() - const { data: currentUserId, isPending: isCurrentUserIdLoading } = - useCurrentUserId() - const showUserContextualContent = isCurrentUserIdLoading || !!currentUserId + const { data: currentUserId } = useCurrentUserId() + const isAccountLoaded = useIsAccountLoaded() + // While the account is still resolving (e.g. during a manager-mode account + // switch), keep the personalized layout in place rather than flashing the + // unauthenticated view. + const showUserContextualContent = !isAccountLoaded || !!currentUserId const { setCenter, setRight } = useContext(NavContext)! From e52fb356279236dd3d87a077367666b395e4f367 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 8 May 2026 15:22:08 -0500 Subject: [PATCH 3/9] Add home navigation and refresh home page layout --- .../src/components/bottom-bar/BottomBar.tsx | 27 ++++----- .../bottom-bar/buttons/HomeButton.tsx | 58 +++++++++++++++++++ .../nav/mobile/ConnectedBottomBar.tsx | 22 +++---- .../components/nav/mobile/LeftNavDrawer.tsx | 11 ++++ .../pages/home-page/components/QuickLinks.tsx | 11 ++-- .../home-page/components/desktop/HomePage.tsx | 10 ++-- .../home-page/components/mobile/HomePage.tsx | 27 ++++++--- .../web/src/public-site/components/Footer.tsx | 4 +- .../landing-2026/components/Hero2026.tsx | 4 +- .../pages/landing-2026/components/Nav2026.tsx | 4 +- 10 files changed, 126 insertions(+), 52 deletions(-) create mode 100644 packages/web/src/components/bottom-bar/buttons/HomeButton.tsx diff --git a/packages/web/src/components/bottom-bar/BottomBar.tsx b/packages/web/src/components/bottom-bar/BottomBar.tsx index f1a7a033224..744b560b05f 100644 --- a/packages/web/src/components/bottom-bar/BottomBar.tsx +++ b/packages/web/src/components/bottom-bar/BottomBar.tsx @@ -6,7 +6,7 @@ import { useLocation } from 'react-router' import { RouterContext } from 'components/animated-switch/RouterContextProvider' import ExploreButton from 'components/bottom-bar/buttons/ExploreButton' import FeedButton from 'components/bottom-bar/buttons/FeedButton' -import LibraryButton from 'components/bottom-bar/buttons/LibraryButton' +import HomeButton from 'components/bottom-bar/buttons/HomeButton' import NotificationsButton from 'components/bottom-bar/buttons/NotificationsButton' import TrendingButton from 'components/bottom-bar/buttons/TrendingButton' @@ -24,17 +24,16 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - FAVORITES_PAGE, - LIBRARY_PAGE, + HOMEPAGE_PAGE, NOTIFICATION_PAGE } = route type Props = { currentPage: string + onClickHome: () => void onClickFeed: () => void onClickTrending: () => void onClickExplore: () => void - onClickLibrary: () => void onClickNotifications: () => void isDarkMode: boolean isMatrixMode: boolean @@ -42,10 +41,10 @@ type Props = { const BottomBar = ({ currentPage, + onClickHome, onClickFeed, onClickTrending, onClickExplore, - onClickLibrary, onClickNotifications, isDarkMode, isMatrixMode @@ -68,6 +67,14 @@ const BottomBar = ({ return window.ReactNativeWebView?.postMessage ? null : (
+ - { + const handleClick = useCallback( + (e: MouseEvent) => { + e.preventDefault() + onClick() + }, + [onClick] + ) + + const rootProps = { + onClick: handleClick, + className: styles.animatedButton + } + + const content = ( +
+ +
+ ) + + return href ? ( + + {content} + + ) : ( + + ) +} + +export default memo(HomeButton) diff --git a/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx b/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx index 572eff2fe7c..f094b21002b 100644 --- a/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx +++ b/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx @@ -16,7 +16,7 @@ const { FEED_PAGE, TRENDING_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE, + HOMEPAGE_PAGE, NOTIFICATION_PAGE } = route @@ -32,15 +32,15 @@ const ConnectedBottomBar = () => { isGuestAccount: selectIsGuestAccount(user) }) }) - const { handle, isGuestAccount } = accountData ?? {} + const { handle } = accountData ?? {} // Memoize navRoutes to avoid recreating Set on every render const navRoutes = useMemo(() => { return new Set([ + HOMEPAGE_PAGE, TRENDING_PAGE, FEED_PAGE, EXPLORE_PAGE, - LIBRARY_PAGE, NOTIFICATION_PAGE ]) }, []) @@ -48,7 +48,7 @@ const ConnectedBottomBar = () => { // Use ref to track last nav route synchronously (avoids render loops) // This is critical for React Router v7 compatibility where location updates // can happen before component re-renders - const lastNavRouteRef = useRef(TRENDING_PAGE) + const lastNavRouteRef = useRef(HOMEPAGE_PAGE) const currentRoute = getPathname(location) // Compute current page synchronously: use current route if it's a nav route, @@ -78,6 +78,10 @@ const ConnectedBottomBar = () => { dispatch(showRequiresAccountToast()) }, [dispatch]) + const goToHome = useCallback(() => { + goToRoute(HOMEPAGE_PAGE) + }, [goToRoute]) + const goToFeed = useCallback(() => { if (!handle) { handleOpenSignOn() @@ -94,14 +98,6 @@ const ConnectedBottomBar = () => { goToRoute(EXPLORE_PAGE) }, [goToRoute]) - const goToLibrary = useCallback(() => { - if (!handle && !isGuestAccount) { - handleOpenSignOn() - } else { - goToRoute(LIBRARY_PAGE) - } - }, [goToRoute, handle, isGuestAccount, handleOpenSignOn]) - const goToNotifications = useCallback(() => { if (!handle) { handleOpenSignOn() @@ -113,10 +109,10 @@ const ConnectedBottomBar = () => { return ( { onNavigate={handleNavigate} /> ) : null} + {currentUserId ? ( + + ) : null} { return ( - {pills.map((pill) => ( - - ))} + + {pills.map((pill) => ( + + ))} + ) } diff --git a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx index 5d3b3240857..2b2ad039834 100644 --- a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx @@ -102,14 +102,14 @@ export const DesktopHomePage = ({ element: }, { - key: 'mood-grid', + key: 'artist-spotlight', shouldRender: true, - element: + element: }, { - key: 'artist-spotlight', - shouldRender: !showUserContextualContent, - element: + key: 'mood-grid', + shouldRender: true, + element: } ], [showUserContextualContent, onMoodClick] diff --git a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx index 81a40134ffc..0bdfd2ace4a 100644 --- a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx @@ -13,6 +13,8 @@ import { Flex } from '@audius/harmony' import type { Mood } from '@audius/sdk' import { useNavigate } from 'react-router' +import Header from 'components/header/mobile/Header' +import { HeaderContext } from 'components/header/mobile/HeaderContextProvider' import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' import NavContext, { CenterPreset } from 'components/nav/mobile/NavContext' @@ -48,12 +50,17 @@ export const MobileHomePage = (_props: MobileHomePageProps) => { const showUserContextualContent = !isAccountLoaded || !!currentUserId const { setCenter, setRight } = useContext(NavContext)! + const { setHeader } = useContext(HeaderContext) useEffect(() => { setRight(null) setCenter(CenterPreset.LOGO) }, [setCenter, setRight]) + useEffect(() => { + setHeader(
) + }, [setHeader]) + const onMoodClick = useCallback( (mood: Mood) => { navigate(route.searchPage({ category: 'tracks', mood })) @@ -111,14 +118,14 @@ export const MobileHomePage = (_props: MobileHomePageProps) => { element: }, { - key: 'mood-grid', + key: 'artist-spotlight', shouldRender: true, - element: + element: }, { - key: 'artist-spotlight', - shouldRender: !showUserContextualContent, - element: + key: 'mood-grid', + shouldRender: true, + element: } ], [showUserContextualContent, onMoodClick] @@ -130,10 +137,12 @@ export const MobileHomePage = (_props: MobileHomePageProps) => { containerClassName='home-page' hasDefaultHeader > - - {sectionConfigs.map(({ key, shouldRender, element }) => - shouldRender ? {element} : null - )} + + + {sectionConfigs.map(({ key, shouldRender, element }) => + shouldRender ? {element} : null + )} + ) diff --git a/packages/web/src/public-site/components/Footer.tsx b/packages/web/src/public-site/components/Footer.tsx index d4b448645bf..bdf85b1fde8 100644 --- a/packages/web/src/public-site/components/Footer.tsx +++ b/packages/web/src/public-site/components/Footer.tsx @@ -22,7 +22,7 @@ const { TERMS_OF_SERVICE, API_TERMS, OPEN_MUSIC_LICENSE_LINK, - TRENDING_PAGE, + HOMEPAGE_PAGE, AUDIUS_BLOG_LINK, DOWNLOAD_LINK, AUDIUS_HELP_LINK, @@ -128,7 +128,7 @@ const Footer = (props: FooterProps) => {

{messages.product}

{ const navigate = useNavigate() const onGetStarted = (e: MouseEvent) => { - handleClickRoute(TRENDING_PAGE, props.setRenderPublicSite, navigate)(e) + handleClickRoute(HOMEPAGE_PAGE, props.setRenderPublicSite, navigate)(e) } return ( diff --git a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx index 83fa45b423a..3bebd42a2c4 100644 --- a/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx +++ b/packages/web/src/public-site/pages/landing-2026/components/Nav2026.tsx @@ -28,7 +28,7 @@ import IconHelpSupport from '../assets/icon-help-support.svg' import styles from './Nav2026.module.css' -const { SIGN_UP_PAGE, TRENDING_PAGE, DOWNLOAD_LINK } = route +const { SIGN_UP_PAGE, HOMEPAGE_PAGE, DOWNLOAD_LINK } = route const messages = { signUp: 'Sign Up', @@ -154,7 +154,7 @@ export const Nav2026 = (props: Nav2026Props) => { const onCtaClick = (e: MouseEvent) => { setIsMobileOverlayOpen(false) - const routeToUse = isAuthenticated ? TRENDING_PAGE : SIGN_UP_PAGE + const routeToUse = isAuthenticated ? HOMEPAGE_PAGE : SIGN_UP_PAGE handleClickRoute(routeToUse, setRenderPublicSite, navigate)(e) } From c58ae1c22fa7d012144754237f3b7e010e8afd7a Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 8 May 2026 18:53:39 -0500 Subject: [PATCH 4/9] Fix home page auth loading flicker --- .../pages/home-page/components/UnauthHero.tsx | 49 ++++++++++--------- .../home-page/components/desktop/HomePage.tsx | 13 +++-- .../home-page/components/mobile/HomePage.tsx | 13 +++-- 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/web/src/pages/home-page/components/UnauthHero.tsx b/packages/web/src/pages/home-page/components/UnauthHero.tsx index cb6c26982d8..40d3617e0e8 100644 --- a/packages/web/src/pages/home-page/components/UnauthHero.tsx +++ b/packages/web/src/pages/home-page/components/UnauthHero.tsx @@ -4,40 +4,45 @@ import { route } from '@audius/common/utils' import { Button, Flex, IconArrowRight, Paper, Text } from '@audius/harmony' import { useNavigate } from 'react-router' +import { useIsMobile } from 'hooks/useIsMobile' + const { SIGN_UP_PAGE } = route const messages = { - title: 'Welcome to Audius', + title: 'Find your people. Grow your scene.', subtitle: - 'Discover new music, support artists directly, and join a community owned by its creators.', - signUp: 'Sign Up Free' + 'Audius is the community-run platform for artists, labels, and music lovers pushing scenes forward. Free to use, ad-free, no upload limits.', + signUp: 'Get Started' } export const UnauthHero = () => { const navigate = useNavigate() + const isMobile = useIsMobile() const onSignUp = useCallback(() => { navigate(SIGN_UP_PAGE) }, [navigate]) return ( - - - - {messages.title} - - - {messages.subtitle} - - - - + + + + + {messages.title} + + + {messages.subtitle} + + + + + ) } diff --git a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx index 2b2ad039834..29ba8065297 100644 --- a/packages/web/src/pages/home-page/components/desktop/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx @@ -9,6 +9,7 @@ import { useNavigate } from 'react-router' import { MIN_DESKTOP_CONTENT_WIDTH_PX } from 'common/utils/layout' import { Header } from 'components/header/desktop/Header' import Page from 'components/page/Page' +import { localStorage } from 'services/local-storage' import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' @@ -40,10 +41,14 @@ export const DesktopHomePage = ({ const navigate = useNavigate() const { data: currentUserId } = useCurrentUserId() const isAccountLoaded = useIsAccountLoaded() - // While the account is still resolving (e.g. during a manager-mode account - // switch), keep the personalized layout in place rather than flashing the - // unauthenticated view. - const showUserContextualContent = !isAccountLoaded || !!currentUserId + // While the account is still resolving, fall back to the synchronous + // localStorage hint to decide what to render. Without this, unauth visitors + // flash the personalized layout before the unauth filler swaps in (and + // authed users flashed the unauth filler before personalized loaded). + const cachedHasAccount = localStorage.getAudiusAccountSync()?.userId != null + const showUserContextualContent = isAccountLoaded + ? !!currentUserId + : cachedHasAccount const onMoodClick = useCallback( (mood: Mood) => { diff --git a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx index 0bdfd2ace4a..86a9d33d77c 100644 --- a/packages/web/src/pages/home-page/components/mobile/HomePage.tsx +++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx @@ -17,6 +17,7 @@ import Header from 'components/header/mobile/Header' import { HeaderContext } from 'components/header/mobile/HeaderContextProvider' import MobilePageContainer from 'components/mobile-page-container/MobilePageContainer' import NavContext, { CenterPreset } from 'components/nav/mobile/NavContext' +import { localStorage } from 'services/local-storage' import { ArtistSpotlightSection } from '../../../search-explore-page/components/desktop/ArtistSpotlightSection' import { FeaturedPlaylistsSection } from '../../../search-explore-page/components/desktop/FeaturedPlaylistsSection' @@ -44,10 +45,14 @@ export const MobileHomePage = (_props: MobileHomePageProps) => { const navigate = useNavigate() const { data: currentUserId } = useCurrentUserId() const isAccountLoaded = useIsAccountLoaded() - // While the account is still resolving (e.g. during a manager-mode account - // switch), keep the personalized layout in place rather than flashing the - // unauthenticated view. - const showUserContextualContent = !isAccountLoaded || !!currentUserId + // While the account is still resolving, fall back to the synchronous + // localStorage hint to decide what to render. Without this, unauth visitors + // flash the personalized layout before the unauth filler swaps in (and + // authed users flashed the unauth filler before personalized loaded). + const cachedHasAccount = localStorage.getAudiusAccountSync()?.userId != null + const showUserContextualContent = isAccountLoaded + ? !!currentUserId + : cachedHasAccount const { setCenter, setRight } = useContext(NavContext)! const { setHeader } = useContext(HeaderContext) From 31da3f33239db62733fcf95ee5ae48f2b5504d74 Mon Sep 17 00:00:00 2001 From: julian Date: Fri, 8 May 2026 19:01:52 -0500 Subject: [PATCH 5/9] Add fade masks to desktop carousels --- .../components/desktop/Carousel.tsx | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx index 13b6c24dcce..0ed5467e3bb 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx @@ -18,6 +18,18 @@ export type CarouselProps = { viewAllLink?: string } +const FADE_LENGTH_PX = 64 + +const getFadeMask = (canScrollLeft: boolean, canScrollRight: boolean) => { + const leftStop = canScrollLeft + ? `transparent 0, black ${FADE_LENGTH_PX}px` + : 'black 0' + const rightStop = canScrollRight + ? `black calc(100% - ${FADE_LENGTH_PX}px), transparent 100%` + : 'black 100%' + return `linear-gradient(to right, ${leftStop}, ${rightStop})` +} + export const Carousel = forwardRef( ({ title, children, viewAllLink }, ref) => { const [canScrollLeft, setCanScrollLeft] = useState(false) @@ -165,7 +177,18 @@ export const Carousel = forwardRef( paddingLeft: railInset, paddingRight: railInset, paddingTop: railShadowPaddingTop, - paddingBottom: railShadowPaddingBottom + paddingBottom: railShadowPaddingBottom, + + // Desktop only: fade content into the page at edges when there's + // more to scroll in that direction. Avoids the abrupt edge of a + // finite carousel without repeating content. Fade region needs to + // be deep enough to land on actual card content (cards start + // ~railInset + contentInset from the scroll-container edge), so + // we use a fixed length larger than that. + ...(!isMobile && { + WebkitMaskImage: getFadeMask(canScrollLeft, canScrollRight), + maskImage: getFadeMask(canScrollLeft, canScrollRight) + }) }} > Date: Sat, 9 May 2026 11:25:31 -0500 Subject: [PATCH 6/9] Refine home quick links and rewards card --- .../pages/home-page/components/QuickLinks.tsx | 50 +++++++++---------- .../components/RewardsSummaryCard.tsx | 13 ++++- .../pages/home-page/components/StatusZone.tsx | 4 +- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/packages/web/src/pages/home-page/components/QuickLinks.tsx b/packages/web/src/pages/home-page/components/QuickLinks.tsx index 5b51788aa68..96c3fe198f8 100644 --- a/packages/web/src/pages/home-page/components/QuickLinks.tsx +++ b/packages/web/src/pages/home-page/components/QuickLinks.tsx @@ -13,9 +13,9 @@ import { Flex, IconCloudUpload, IconDiscord, + IconFanClub, + IconGift, IconQuestionCircle, - IconStar, - IconTokenAUDIO, IconTrophy, IconUser, IconVerified, @@ -26,6 +26,8 @@ import { import { GetContestsByUserStatusEnum } from '@audius/sdk' import { useNavigate } from 'react-router' +import { useIsMobile } from 'hooks/useIsMobile' + const { AUDIUS_DISCORD_LINK, AUDIUS_HELP_LINK, @@ -52,7 +54,8 @@ const messages = { joinDiscord: 'Discord', support: 'Support', signUp: 'Sign Up', - rewardsLabel: '$AUDIO' + rewards: 'Rewards', + audioUnit: '$AUDIO' } type Pill = { @@ -104,15 +107,8 @@ const PillItem = ({ pill }: { pill: Pill }) => { ) } -type QuickLinksProps = { - /** - * When true, surfaces a "Rewards" pill in the row showing claimable $AUDIO. - * Desktop omits this because RewardsSummaryCard already covers it. - */ - showRewardsPill?: boolean -} - -export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { +export const QuickLinks = () => { + const isMobile = useIsMobile() const { data: currentUserId } = useCurrentUserId() const { data: currentUser } = useCurrentAccountUser() const isAuthed = !!currentUserId @@ -167,15 +163,16 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { const items: Pill[] = [] - if (showRewardsPill && !isRewardsEmpty) { - items.push({ - key: 'rewards', - label: `${formatNumberCommas(claimableAmount)} ${messages.rewardsLabel}`, - icon: IconTokenAUDIO, - to: REWARDS_PAGE, - highlight: true - }) - } + const hasClaimable = !isRewardsEmpty && claimableAmount > 0 + items.push({ + key: 'rewards', + label: hasClaimable + ? `${formatNumberCommas(claimableAmount)} ${messages.audioUnit}` + : messages.rewards, + icon: IconGift, + to: REWARDS_PAGE, + highlight: hasClaimable + }) items.push({ key: 'upload', @@ -224,14 +221,14 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { items.push({ key: 'manage-fan-club', label: messages.manageFanClub, - icon: IconStar, + icon: IconFanClub, to: clubPage(createdFanClub.ticker) }) } else if (isVerified) { items.push({ key: 'launch-fan-club', label: messages.launchFanClub, - icon: IconStar, + icon: IconFanClub, to: CLUBS_CREATE_PAGE }) } @@ -265,7 +262,6 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { return items }, [ isAuthed, - showRewardsPill, isRewardsEmpty, claimableAmount, hostedContestTrackIds, @@ -289,7 +285,11 @@ export const QuickLinks = ({ showRewardsPill = false }: QuickLinksProps) => { '&::-webkit-scrollbar': { display: 'none' } }} > - + {pills.map((pill) => ( ))} diff --git a/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx index 2466cb4510e..27c729de9e4 100644 --- a/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx +++ b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx @@ -23,7 +23,16 @@ const messages = { viewAll: 'View All Rewards' } -export const RewardsSummaryCard = () => { +type RewardsSummaryCardProps = { + /** + * When true, bypasses the empty-state hide. Intended for testing/QA only. + */ + forceVisible?: boolean +} + +export const RewardsSummaryCard = ({ + forceVisible = false +}: RewardsSummaryCardProps = {}) => { const { claimableAmount, isEmpty } = useChallengeCooldownSchedule({ multiple: true }) @@ -38,7 +47,7 @@ export const RewardsSummaryCard = () => { navigate(REWARDS_PAGE) }, [navigate]) - if (isEmpty) return null + if (isEmpty && !forceVisible) return null return ( diff --git a/packages/web/src/pages/home-page/components/StatusZone.tsx b/packages/web/src/pages/home-page/components/StatusZone.tsx index 76faf1f8d41..274c54982b1 100644 --- a/packages/web/src/pages/home-page/components/StatusZone.tsx +++ b/packages/web/src/pages/home-page/components/StatusZone.tsx @@ -53,18 +53,18 @@ export const StatusZone = ({ variant }: StatusZoneProps) => { // surfaces as a pill in QuickLinks instead. return ( - + ) } return ( - + ) From 2ff1fe34a57cf3f29b4635333d85f22a08a4431b Mon Sep 17 00:00:00 2001 From: julian Date: Sat, 9 May 2026 11:57:57 -0500 Subject: [PATCH 7/9] Improve carousel snap scrolling and edge fades --- .../components/desktop/Carousel.tsx | 236 +++++++++++++----- 1 file changed, 177 insertions(+), 59 deletions(-) diff --git a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx index 0ed5467e3bb..53648bb6a36 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx @@ -1,12 +1,14 @@ import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' import { + Box, Flex, IconButton, IconCaretLeft, IconCaretRight, Text, - PlainButton + PlainButton, + useTheme } from '@audius/harmony' import { Link } from 'react-router' @@ -18,17 +20,13 @@ export type CarouselProps = { viewAllLink?: string } -const FADE_LENGTH_PX = 64 - -const getFadeMask = (canScrollLeft: boolean, canScrollRight: boolean) => { - const leftStop = canScrollLeft - ? `transparent 0, black ${FADE_LENGTH_PX}px` - : 'black 0' - const rightStop = canScrollRight - ? `black calc(100% - ${FADE_LENGTH_PX}px), transparent 100%` - : 'black 100%' - return `linear-gradient(to right, ${leftStop}, ${rightStop})` -} +const FADE_LENGTH_PX = 24 +const SCROLL_DURATION_MS = 320 +// scroll-margin-left applied to every non-first card so the prior card peeks +// at the left edge during snap. Mirrored in CSS below; duplicated as a JS +// constant so the caret-press animation can compute the exact snap-aligned +// scrollLeft target and avoid the snap-back "jump" at the end of animation. +const NON_FIRST_SNAP_MARGIN_PX = 40 export const Carousel = forwardRef( ({ title, children, viewAllLink }, ref) => { @@ -36,7 +34,10 @@ export const Carousel = forwardRef( const [canScrollRight, setCanScrollRight] = useState(true) const scrollContainerRef = useRef(null) const rafRef = useRef(null) + const animRef = useRef(null) const isMobile = useIsMobile() + const theme = useTheme() + const pageBg = theme.color.background.default const updateScrollButtons = useCallback(() => { if (rafRef.current !== null) return @@ -73,25 +74,101 @@ export const Carousel = forwardRef( // so card shadows (including hover states) are not cut between carousels. const railShadowPaddingTop = isMobile ? 12 : 10 const railShadowPaddingBottom = isMobile ? 12 : 20 + + // JS-driven smooth scroll for caret presses. Native smooth scroll + + // scroll-snap fight each other in this layout, so we rAF-drive scrollLeft + // directly with ease-out. We disable snap for the duration of the + // animation, but pre-compute the exact snap-aligned scrollLeft target so + // that when snap re-engages at the end, the browser sees we're already + // aligned and doesn't pull anywhere — no end-of-animation jump. const handleScrollBy = useCallback( (direction: -1 | 1) => { const container = scrollContainerRef.current if (!container) return + const innerRail = container.firstElementChild as HTMLElement | null + if (!innerRail) return + if (animRef.current !== null) { + cancelAnimationFrame(animRef.current) + animRef.current = null + } + + const scrollPaddingLeft = railInset + contentInset + const start = container.scrollLeft + const maxScroll = container.scrollWidth - container.clientWidth + const containerLeft = container.getBoundingClientRect().left + + // Build the list of snap-aligned scrollLeft positions, one per card. + // Mirrors the CSS snap rules: padding on the container, plus + // scroll-margin-left on every non-first card. + const cards = Array.from(innerRail.children) as HTMLElement[] + const snapPositions = cards.map((card, i) => { + const cardLeftInContainer = + card.getBoundingClientRect().left - containerLeft + start + const cardMargin = i === 0 ? 0 : NON_FIRST_SNAP_MARGIN_PX + return Math.max( + 0, + Math.min( + cardLeftInContainer - cardMargin - scrollPaddingLeft, + maxScroll + ) + ) + }) - // Scroll by nearly one viewport of rail content so nav remains aligned - // across responsive widths without hardcoded pixel jumps. - const scrollAmount = Math.max( + // Pick the snap position closest to where a viewport-sized scroll in + // the requested direction would have landed. + const desiredDistance = Math.max( 240, - container.clientWidth - (railInset + contentInset) * 2 - 24 + container.clientWidth - scrollPaddingLeft * 2 - 24 ) - container.scrollBy({ - left: direction * scrollAmount, - behavior: 'smooth' - }) + const desiredTarget = Math.max( + 0, + Math.min(start + direction * desiredDistance, maxScroll) + ) + let target = desiredTarget + let bestDist = Infinity + for (const pos of snapPositions) { + // Skip snap targets in the wrong direction (with a small epsilon so + // we don't get stuck at the current position). + if (direction === 1 && pos <= start + 1) continue + if (direction === -1 && pos >= start - 1) continue + const dist = Math.abs(pos - desiredTarget) + if (dist < bestDist) { + bestDist = dist + target = pos + } + } + + const startTime = performance.now() + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3) + const previousSnapType = container.style.scrollSnapType + container.style.scrollSnapType = 'none' + const tick = (now: number) => { + const elapsed = now - startTime + const progress = Math.min(elapsed / SCROLL_DURATION_MS, 1) + container.scrollLeft = start + (target - start) * easeOut(progress) + if (progress < 1) { + animRef.current = requestAnimationFrame(tick) + } else { + animRef.current = null + // We landed exactly on a snap-aligned scrollLeft; restoring snap + // here is a no-op for the browser. + container.style.scrollSnapType = previousSnapType + } + } + animRef.current = requestAnimationFrame(tick) }, [railInset] ) + useEffect( + () => () => { + if (animRef.current !== null) { + cancelAnimationFrame(animRef.current) + } + }, + [] + ) + return ( ( ) : null} - + - {children} + *': { scrollSnapAlign: 'start' }, + // For non-first cards, push the snap point inward so the prior + // card peeks at the left edge (and catches the fade overlay) + // rather than being scrolled fully off-screen. + '& > * + *': { + scrollMarginLeft: `${NON_FIRST_SNAP_MARGIN_PX}px` + } + }} + > + {children} + - + {/* Edge-fade overlays (desktop only). Rendered as absolute siblings + of the scroll container so they don't interfere with scroll + compositing — applying `mask-image` on the scrollable element + itself forces a non-composited paint mode that breaks the + smooth-scroll animation. */} + {!isMobile && canScrollLeft ? ( + + ) : null} + {!isMobile && canScrollRight ? ( + + ) : null} + ) } From 570d04549daef573a4ae919fc7b7c9abde3f2952 Mon Sep 17 00:00:00 2001 From: julian Date: Sat, 9 May 2026 17:19:57 -0500 Subject: [PATCH 8/9] Stabilize carousel edge-fade overlays Always mount the left/right gradient overlays and drive visibility with an opacity transition instead of conditionally rendering them. The mount/unmount churn from canScrollLeft / canScrollRight flipping during snap settling was producing visible flicker on the gradients and a brief content "blip" each time the Carousel re-rendered. Co-Authored-By: Claude Opus 4.7 --- .../components/desktop/Carousel.tsx | 68 ++++++++++--------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx index 53648bb6a36..11d5b14c9d5 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx @@ -287,38 +287,42 @@ export const Carousel = forwardRef( {children} - {/* Edge-fade overlays (desktop only). Rendered as absolute siblings - of the scroll container so they don't interfere with scroll - compositing — applying `mask-image` on the scrollable element - itself forces a non-composited paint mode that breaks the - smooth-scroll animation. */} - {!isMobile && canScrollLeft ? ( - - ) : null} - {!isMobile && canScrollRight ? ( - + {/* Edge-fade overlays (desktop only). Always mounted; visibility is + driven by opacity transitions instead of conditional rendering so + we don't remount the gradient div every time canScrollLeft / + canScrollRight flip (which can happen rapidly during snap + settling and was causing the overlay flicker). */} + {!isMobile ? ( + <> + + + ) : null}
From 17dab0d56a82e6672c62ae4742bb51b7a68b19d4 Mon Sep 17 00:00:00 2001 From: julian Date: Sat, 9 May 2026 18:35:46 -0500 Subject: [PATCH 9/9] Lift carousel edge-fade overlays above card stacking context Card hover/shadow styling can promote a card to its own stacking context, which makes the fade overlay paint underneath that card despite DOM order. Explicit z-index on both overlays keeps them reliably on top. Co-Authored-By: Claude Opus 4.7 --- .../search-explore-page/components/desktop/Carousel.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx index 11d5b14c9d5..3807c2b58da 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/Carousel.tsx @@ -305,7 +305,11 @@ export const Carousel = forwardRef( background: `linear-gradient(to right, ${pageBg}, transparent)`, pointerEvents: 'none', opacity: canScrollLeft ? 1 : 0, - transition: 'opacity 150ms ease-out' + transition: 'opacity 150ms ease-out', + // Card hover/shadow styling can promote individual cards to + // their own stacking context; explicit z-index keeps the + // fade overlay reliably on top of any card paint. + zIndex: 2 }} /> ( background: `linear-gradient(to left, ${pageBg}, transparent)`, pointerEvents: 'none', opacity: canScrollRight ? 1 : 0, - transition: 'opacity 150ms ease-out' + transition: 'opacity 150ms ease-out', + zIndex: 2 }} />