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 8cf115de535..0873232cdd2 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,16 @@ const CoinExclusiveTracksLegacyRedirect = () => {
type HomePageRedirectProps = {
isGuestAccount: boolean
+ target?: string
}
-const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => {
+const HomePageRedirect = ({
+ isGuestAccount,
+ // Default to FEED_PAGE to align with main's "feed is the default" choice
+ // for any caller that doesn't pass an explicit target. Active mounts in
+ // this file pass `target={HOMEPAGE_PAGE}` to land on the new home page.
+ target = FEED_PAGE
+}: HomePageRedirectProps) => {
const location = useLocation()
const currentPath = getPathname(location)
const to = {
@@ -437,7 +446,7 @@ const HomePageRedirect = ({ isGuestAccount }: HomePageRedirectProps) => {
currentPath === HOME_PAGE
? isGuestAccount
? LIBRARY_PAGE
- : FEED_PAGE
+ : target
: currentPath,
search: includeSearch(location.search) ? location.search : ''
}
@@ -1282,9 +1291,15 @@ const WebPlayer = (props: WebPlayerProps) => {
path={PROFILE_PAGE}
element={}
/>
+ } />
}
+ element={
+
+ }
/>
) : (
@@ -1632,9 +1647,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/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/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/nav/mobile/ConnectedBottomBar.tsx b/packages/web/src/components/nav/mobile/ConnectedBottomBar.tsx
index c25f01800c5..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(FEED_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}
{
+ 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 +104,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 +114,127 @@ 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..d22594302ea 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={{ 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 2b168d96db1..99cf53f112f 100644
--- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx
+++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx
@@ -29,7 +29,7 @@ import EmptyFeed from 'pages/feed-page/components/EmptyFeed'
import { FeedTabs } from 'pages/feed-page/components/FeedTabs'
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 850b00dc22a..088fcccc048 100644
--- a/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx
+++ b/packages/web/src/pages/feed-page/components/mobile/FeedPageContent.tsx
@@ -35,7 +35,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/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..96c3fe198f8
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/QuickLinks.tsx
@@ -0,0 +1,299 @@
+import { useMemo } from 'react'
+
+import {
+ useArtistCreatedFanClub,
+ useCurrentAccountUser,
+ useCurrentUserId,
+ useTrack,
+ useUserRemixContests
+} from '@audius/common/api'
+import { useChallengeCooldownSchedule, useIsArtist } from '@audius/common/hooks'
+import { formatNumberCommas, route } from '@audius/common/utils'
+import {
+ Flex,
+ IconCloudUpload,
+ IconDiscord,
+ IconFanClub,
+ IconGift,
+ IconQuestionCircle,
+ IconTrophy,
+ IconUser,
+ IconVerified,
+ Paper,
+ Text,
+ useTheme
+} from '@audius/harmony'
+import { GetContestsByUserStatusEnum } from '@audius/sdk'
+import { useNavigate } from 'react-router'
+
+import { useIsMobile } from 'hooks/useIsMobile'
+
+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,
+ profilePage
+} = route
+
+const messages = {
+ hostContest: 'Host a Contest',
+ manageContest: 'Manage Contest',
+ manageMultipleContests: 'Manage Contests',
+ launchFanClub: 'Launch a Fan Club',
+ manageFanClub: 'Manage Fan Club',
+ uploadTrack: 'Upload',
+ yourProfile: 'Your Profile',
+ getVerified: 'Get Verified',
+ joinDiscord: 'Discord',
+ support: 'Support',
+ signUp: 'Sign Up',
+ rewards: 'Rewards',
+ audioUnit: '$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}
+
+
+ )
+}
+
+export const QuickLinks = () => {
+ const isMobile = useIsMobile()
+ 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 isArtist = useIsArtist({ id: currentUserId ?? undefined })
+ const isVerified = !!currentUser?.is_verified
+ const currentUserHandle = currentUser?.handle
+
+ 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[] = []
+
+ 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',
+ label: messages.uploadTrack,
+ icon: IconCloudUpload,
+ to: UPLOAD_PAGE
+ })
+
+ if (currentUserHandle) {
+ items.push({
+ 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',
+ 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: IconFanClub,
+ to: clubPage(createdFanClub.ticker)
+ })
+ } else if (isVerified) {
+ items.push({
+ key: 'launch-fan-club',
+ label: messages.launchFanClub,
+ icon: IconFanClub,
+ to: CLUBS_CREATE_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,
+ isRewardsEmpty,
+ claimableAmount,
+ hostedContestTrackIds,
+ hostedTrack,
+ createdFanClub,
+ isArtist,
+ isVerified,
+ currentUserHandle
+ ])
+
+ 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..27c729de9e4
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/RewardsSummaryCard.tsx
@@ -0,0 +1,88 @@
+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'
+}
+
+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
+ })
+ const [, setClaimAllRewardsVisibility] = useModalState('ClaimAllRewards')
+ const navigate = useNavigate()
+
+ const onClickClaim = useCallback(() => {
+ setClaimAllRewardsVisibility(true)
+ }, [setClaimAllRewardsVisibility])
+
+ const onClickViewAll = useCallback(() => {
+ navigate(REWARDS_PAGE)
+ }, [navigate])
+
+ if (isEmpty && !forceVisible) 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..274c54982b1
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/StatusZone.tsx
@@ -0,0 +1,71 @@
+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..40d3617e0e8
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/UnauthHero.tsx
@@ -0,0 +1,48 @@
+import { useCallback } from 'react'
+
+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: 'Find your people. Grow your scene.',
+ subtitle:
+ '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}
+
+
+
+
+
+ )
+}
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..29ba8065297
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/desktop/HomePage.tsx
@@ -0,0 +1,154 @@
+import { Fragment, ReactNode, useCallback, useMemo } from 'react'
+
+import { useCurrentUserId, useIsAccountLoaded } 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 { localStorage } from 'services/local-storage'
+
+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 } = useCurrentUserId()
+ const isAccountLoaded = useIsAccountLoaded()
+ // 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) => {
+ 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: 'artist-spotlight',
+ shouldRender: true,
+ element:
+ },
+ {
+ key: 'mood-grid',
+ shouldRender: true,
+ 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..86a9d33d77c
--- /dev/null
+++ b/packages/web/src/pages/home-page/components/mobile/HomePage.tsx
@@ -0,0 +1,154 @@
+import {
+ Fragment,
+ ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo
+} from 'react'
+
+import { useCurrentUserId, useIsAccountLoaded } 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 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'
+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 } = useCurrentUserId()
+ const isAccountLoaded = useIsAccountLoaded()
+ // 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)
+
+ useEffect(() => {
+ setRight(null)
+ setCenter(CenterPreset.LOGO)
+ }, [setCenter, setRight])
+
+ useEffect(() => {
+ setHeader()
+ }, [setHeader])
+
+ 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: 'artist-spotlight',
+ shouldRender: true,
+ element:
+ },
+ {
+ key: 'mood-grid',
+ shouldRender: true,
+ 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 ? (
(
({ title, children, viewAllLink }, ref) => {
const [canScrollLeft, setCanScrollLeft] = useState(false)
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
@@ -61,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). 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}
+
)
}
diff --git a/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx b/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx
index b986eda0aa5..b63f6fddef8 100644
--- a/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx
+++ b/packages/web/src/pages/search-explore-page/components/desktop/MoodGrid.tsx
@@ -9,7 +9,11 @@ import { useSearchCategory } from 'pages/search-page/hooks'
import { labelByCategoryView } from 'pages/search-page/types'
import { MOODS } from 'utils/Moods'
-export const MoodGrid = () => {
+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/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)
}
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
}