diff --git a/packages/shared/src/components/sidebar/Sidebar.spec.tsx b/packages/shared/src/components/sidebar/Sidebar.spec.tsx
index a370891f3d4..e03a8a9d08d 100644
--- a/packages/shared/src/components/sidebar/Sidebar.spec.tsx
+++ b/packages/shared/src/components/sidebar/Sidebar.spec.tsx
@@ -16,8 +16,38 @@ import { waitForNock } from '../../../__tests__/helpers/utilities';
import ProgressiveEnhancementContext from '../../contexts/ProgressiveEnhancementContext';
import type { Alerts } from '../../graphql/alerts';
import { TOAST_NOTIF_KEY } from '../../hooks/useToastNotification';
+import { SpotlightProvider } from '../spotlight/SpotlightContext';
import { SidebarDesktop } from './SidebarDesktop';
+jest.mock('../notifications/NotificationsBell', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../profile/ProfileButton', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../opportunity/OpportunityEntryButton', () => ({
+ __esModule: true,
+ OpportunityEntryButton: () => null,
+}));
+jest.mock('../header/QuestHeaderButton', () => ({
+ __esModule: true,
+ QuestHeaderButton: () => null,
+}));
+jest.mock('../help/HelpWidget', () => ({
+ __esModule: true,
+ HelpWidget: () => null,
+}));
+jest.mock('../layout/HeaderLogo', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('../cards/highlight/HighlightPostSidebarWidget', () => ({
+ __esModule: true,
+ HighlightPostSidebarWidget: () => null,
+}));
+
let client: QueryClient;
const updateAlerts = jest.fn();
const toggleSidebarExpanded = jest.fn();
@@ -80,11 +110,13 @@ const renderComponent = (
}}
>
-
+
+
+
@@ -95,26 +127,38 @@ const renderComponent = (
it('should render the sidebar as open by default', async () => {
renderComponent();
- const section = await screen.findByText('Discover');
- expect(section).toBeInTheDocument();
- const sectionTwo = await screen.findByText('Squads');
- expect(sectionTwo).toBeInTheDocument();
+ const categoryRail = await screen.findByRole('tablist', {
+ name: 'Sidebar categories',
+ });
+ expect(categoryRail).toBeInTheDocument();
+ expect(await screen.findByText('Explore')).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Home' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
});
it('should toggle the sidebar on button click', async () => {
renderComponent();
- const trigger = await screen.findByLabelText('Close sidebar');
+ // The collapse toggle is `hidden laptop:flex`; jsdom has no laptop
+ // breakpoint so it counts as hidden. Look it up with `hidden: true`
+ // so RTL still finds the always-mounted button.
+ const trigger = await screen.findByLabelText('Close sidebar', {
+ selector: 'button',
+ });
trigger.click();
await waitFor(() => expect(toggleSidebarExpanded).toBeCalled());
});
it('should show the sidebar as closed if user has this set', async () => {
renderComponent(defaultAlerts, [], null, false);
- const trigger = await screen.findByLabelText('Open sidebar');
+ const trigger = await screen.findByLabelText('Open sidebar', {
+ selector: 'button',
+ });
expect(trigger).toBeInTheDocument();
- const section = await screen.findByText('Discover');
- expect(section).toHaveClass('opacity-0');
+ const panel = await screen.findByRole('tabpanel', { hidden: true });
+ expect(panel).toHaveClass('opacity-0');
});
it('should show the For You items if the user has filters', async () => {
@@ -147,8 +191,9 @@ it('should require login before opening following for anonymous users', async ()
);
});
-const sidebarItems = [
- ['Explore', '/posts'],
+const sidebarItems = [['Explore', '/posts']];
+
+const discoverItems = [
['Discussions', '/discussed'],
['Tags', '/tags'],
['Sources', '/sources'],
@@ -167,4 +212,18 @@ describe('sidebar items', () => {
expect(el.closest('a')).toHaveAttribute('href', href);
},
);
+
+ it.each(discoverItems.map((item) => [item[0], item[1]]))(
+ 'it should expect %s to exist in the Discover panel',
+ async (name, href) => {
+ renderComponent();
+ waitForNock();
+ const discoverTab = await screen.findByRole('tab', { name: 'Discover' });
+ fireEvent.click(discoverTab);
+ const el = await screen.findByText(name);
+ expect(el).toBeInTheDocument();
+ // eslint-disable-next-line testing-library/no-node-access
+ expect(el.closest('a')).toHaveAttribute('href', href);
+ },
+ );
});
diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx
index 347ed949036..68fdd3c707c 100644
--- a/packages/shared/src/components/sidebar/Sidebar.tsx
+++ b/packages/shared/src/components/sidebar/Sidebar.tsx
@@ -1,4 +1,4 @@
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import dynamic from 'next/dynamic';
import { useViewSize, ViewSize } from '../../hooks';
@@ -18,13 +18,17 @@ const SidebarDesktop = dynamic(() =>
interface SidebarProps {
activePage: string;
+ additionalButtons?: ReactNode;
isNavButtons?: boolean;
+ showFeedbackWidget?: boolean;
onNavTabClick?: (tab: string) => void;
onLogoClick?: (e: React.MouseEvent) => unknown;
}
export const Sidebar = ({
+ additionalButtons,
isNavButtons,
+ showFeedbackWidget,
onNavTabClick,
onLogoClick,
activePage,
@@ -50,8 +54,11 @@ export const Sidebar = ({
// because router does not update there
activePage={isExtension ? activePage : undefined}
featureTheme={featureTheme}
+ additionalButtons={additionalButtons}
isNavButtons={isNavButtons}
+ showFeedbackWidget={showFeedbackWidget}
onNavTabClick={onNavTabClick}
+ onLogoClick={onLogoClick}
/>
);
}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx
index f11801a9c97..1fe0a566d75 100644
--- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx
+++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx
@@ -1,19 +1,376 @@
import classNames from 'classnames';
-import type { ReactElement } from 'react';
-import React, { useMemo } from 'react';
+import type { ReactElement, ReactNode } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/router';
+import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { Nav, SidebarAside, SidebarScrollWrapper } from './common';
-import { useSettingsContext } from '../../contexts/SettingsContext';
+import {
+ ThemeMode,
+ themes,
+ useSettingsContext,
+} from '../../contexts/SettingsContext';
+import { useLogContext } from '../../contexts/LogContext';
import { useBanner } from '../../hooks/useBanner';
import { MainSection } from './sections/MainSection';
import { CustomFeedSection } from './sections/CustomFeedSection';
import { DiscoverSection } from './sections/DiscoverSection';
-import { SidebarMenuIcon } from './SidebarMenuIcon';
+import { RecentSection } from './sections/RecentSection';
+import { ProfileSection } from './sections/ProfileSection';
+import { SidebarProfileCompletion } from './SidebarProfileCompletion';
+import { SettingsPanelSection } from './sections/SettingsPanelSection';
import { CreatePostButton } from '../post/write';
import { ButtonSize } from '../buttons/Button';
import { BookmarkSection } from './sections/BookmarkSection';
import { NetworkSection } from './sections/NetworkSection';
import { HelpWidget } from '../help/HelpWidget';
+import {
+ BookmarkIcon,
+ FeedbackIcon,
+ HomeIcon,
+ HotIcon,
+ JoystickIcon,
+ MoonIcon,
+ PlusIcon,
+ SearchIcon,
+ SettingsIcon,
+ SidebarArrowLeft,
+ SquadIcon,
+ SunIcon,
+ UserIcon,
+} from '../icons';
+import { ThemeAutoIcon } from '../icons/ThemeAuto';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuOptions,
+ DropdownMenuTrigger,
+} from '../dropdown/DropdownMenu';
+import type { MenuItemProps } from '../dropdown/common';
+import { useSquadNavigation } from '../../hooks';
+import { LogEvent, Origin, TargetType } from '../../lib/log';
+import { IconSize } from '../Icon';
+import {
+ SidebarSelectedCategory,
+ SidebarSettingsFlags,
+} from '../../graphql/settings';
+import { Tooltip } from '../tooltip/Tooltip';
+import { RailHoverPanel } from './RailHoverPanel';
+import { useSpotlight } from '../spotlight/useSpotlight';
+import { useAuthContext } from '../../contexts/AuthContext';
+import NotificationsBell from '../notifications/NotificationsBell';
+import { ProfilePicture, ProfileImageSize } from '../ProfilePicture';
+import { SidebarHeaderStats } from './SidebarHeaderStats';
+import Link from '../utilities/Link';
+import { settingsUrl, webappUrl } from '../../lib/constants';
+import { FeedbackWidget } from '../feedback';
+import { isAppleDevice } from '../../lib/func';
+import LogoIcon from '../../svg/LogoIcon';
+import InteractivePopup, {
+ InteractivePopupPosition,
+} from '../tooltips/InteractivePopup';
+import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup';
+import { ResourceSection } from '../ProfileMenu/sections/ResourceSection';
+import { ProfileMenuFooter } from '../ProfileMenu/ProfileMenuFooter';
+import { HorizontalSeparator } from '../utilities';
+import { QuestButton } from '../quest/QuestButton';
+import { AchievementTrackerPanel } from '../filters/AchievementTrackerButton';
+import { Typography, TypographyType } from '../typography/Typography';
+import { useRecentPagesTracker } from '../../hooks/feed/useRecentPages';
+
+type SidebarCategoryConfig = {
+ id: SidebarSelectedCategory;
+ label: string;
+ icon: (active: boolean) => ReactElement;
+ // Landing path for the rail icon click. Maps each category to the
+ // first navigable item shown in its dedicated panel, so clicking a
+ // rail icon both selects the panel AND opens the matching page (the
+ // first item then highlights as active via `activePage` matching).
+ // Profile is dynamic (needs username) and handled at click time.
+ defaultPath?: string;
+};
+
+const sidebarCategories: SidebarCategoryConfig[] = [
+ {
+ id: SidebarSelectedCategory.Main,
+ label: 'Home',
+ defaultPath: webappUrl,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarSelectedCategory.Squads,
+ label: 'Squads',
+ // `/squads` server-side redirects to `/squads/discover` — link
+ // straight to the destination so we skip the round-trip latency
+ // AND so the resolved `activePage` matches the "Find Squads" item
+ // path (which we also point at `/squads/discover`).
+ defaultPath: `${webappUrl}squads/discover`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarSelectedCategory.Discover,
+ label: 'Discover',
+ // First DiscoverSection item is "Hot Takes" — but it opens a modal
+ // rather than navigating, which would land the user on `/` with a
+ // dialog over it. Use the first real page instead so the URL +
+ // activePage actually moves to a Discover surface.
+ defaultPath: `${webappUrl}tags`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarSelectedCategory.Saved,
+ label: 'Saved',
+ defaultPath: `${webappUrl}bookmarks`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarSelectedCategory.GameCenter,
+ label: 'Game Center',
+ defaultPath: `${webappUrl}game-center`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarSelectedCategory.Profile,
+ label: 'Profile',
+ icon: (active) => (
+
+ ),
+ },
+];
+
+const discoverPathFragments = ['/tags', '/sources', '/users', '/discussed'];
+const profilePathFragments = [
+ '/analytics',
+ '/jobs',
+ '/settings/customization/devcard',
+ '/wallet',
+];
+
+const getSidebarCategoryForPath = (
+ activePage: string,
+): SidebarSelectedCategory => {
+ if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) {
+ return SidebarSelectedCategory.Saved;
+ }
+
+ if (activePage.includes('/squads')) {
+ return SidebarSelectedCategory.Squads;
+ }
+
+ if (activePage.includes('/settings')) {
+ return SidebarSelectedCategory.Settings;
+ }
+
+ if (activePage.includes('/game-center')) {
+ return SidebarSelectedCategory.GameCenter;
+ }
+
+ if (discoverPathFragments.some((path) => activePage.includes(path))) {
+ return SidebarSelectedCategory.Discover;
+ }
+
+ if (profilePathFragments.some((path) => activePage.includes(path))) {
+ return SidebarSelectedCategory.Profile;
+ }
+
+ return SidebarSelectedCategory.Main;
+};
+
+const normalizeSidebarCategory = (
+ category?: SidebarSelectedCategory,
+): SidebarSelectedCategory => {
+ if (!category) {
+ return SidebarSelectedCategory.Main;
+ }
+
+ if (
+ category === SidebarSelectedCategory.Feeds ||
+ category === SidebarSelectedCategory.Settings ||
+ category === SidebarSelectedCategory.GameCenter
+ ) {
+ return SidebarSelectedCategory.Main;
+ }
+
+ return category;
+};
+
+const railButtonClass =
+ 'flex h-10 w-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline';
+const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K'];
+const settingsDefaultPath = `${settingsUrl}/profile`;
+
+const RAIL_HOVER_OPEN_DELAY = 250;
+const RAIL_HOVER_CLOSE_DELAY = 120;
+const RAIL_HOVER_SIDE_OFFSET = 12;
+const RAIL_HOVER_PROFILE_ALIGN_OFFSET = -304;
+// The shared Tooltip primitive bakes in `collisionPadding={{ top: 75 }}`
+// (a leftover from the old global-header layout). On the dual-sidebar
+// laptop layout there's no top chrome, so that default shoves tooltips
+// for icons near the viewport top (Home, Search, expand/collapse)
+// downward — they read as misaligned and visually clipped. A snug
+// override re-centers them with the trigger.
+const RAIL_TOOLTIP_COLLISION_PADDING = 4;
+
+interface RailHoverCardProps {
+ label: string;
+ children: ReactNode;
+ panel: ReactElement;
+ // When `false`, the trigger renders without a popover (used to avoid
+ // duplicating the currently-pinned panel that's already visible).
+ enabled?: boolean;
+ alignOffset?: number;
+}
+
+// Slack-style floating preview anchored to a rail icon. The popover
+// shows the same content as the dedicated panel but is rendered in a
+// portal so it overlays whatever is currently pinned. Radix HoverCard
+// gives us the safe-polygon hover bridge for free, so the cursor can
+// travel from the rail into the popover without dismissing it.
+const RailHoverCard = ({
+ label,
+ children,
+ panel,
+ enabled = true,
+ alignOffset,
+}: RailHoverCardProps) => {
+ if (!enabled) {
+ return <>{children}>;
+ }
+ return (
+
+
+ {children}
+
+
+
+ {panel}
+
+
+
+ );
+};
+
+const themeIconMap: Record<
+ ThemeMode,
+ React.ComponentType<{
+ secondary?: boolean;
+ size?: IconSize;
+ 'aria-hidden'?: boolean;
+ }>
+> = {
+ [ThemeMode.Dark]: MoonIcon,
+ [ThemeMode.Light]: SunIcon,
+ [ThemeMode.Auto]: ThemeAutoIcon,
+};
+
+const SidebarThemeButton = (): ReactElement => {
+ const { setTheme, themeMode } = useSettingsContext();
+ const { logEvent } = useLogContext();
+ const ActiveIcon = themeIconMap[themeMode];
+
+ const onSelectTheme = useCallback(
+ (mode: ThemeMode) => {
+ logEvent({
+ event_name: LogEvent.ChangeSettings,
+ target_type: TargetType.Theme,
+ target_id: mode,
+ });
+ setTheme(mode);
+ },
+ [logEvent, setTheme],
+ );
+
+ const options: MenuItemProps[] = themes.map((theme) => {
+ const Icon = themeIconMap[theme.value];
+ const isActive = theme.value === themeMode;
+ return {
+ label: theme.label,
+ icon:
,
+ action: () => onSelectTheme(theme.value),
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SidebarSupportButton = (): ReactElement => {
+ const { isOpen, onUpdate, wrapHandler } = useInteractivePopup();
+
+ return (
+ <>
+
+ onUpdate(!isOpen))}
+ className={classNames(
+ railButtonClass,
+ isOpen && 'bg-background-default text-text-primary',
+ )}
+ >
+
+
+
+ {isOpen && (
+
onUpdate(false)}
+ position={InteractivePopupPosition.SidebarSupportMenu}
+ className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
+ >
+
+
+
+
+
+ )}
+ >
+ );
+};
type SidebarDesktopProps = {
activePage?: string;
@@ -22,98 +379,654 @@ type SidebarDesktopProps = {
logoText?: string;
};
isNavButtons?: boolean;
+ showFeedbackWidget?: boolean;
onNavTabClick?: (tab: string) => void;
+ onLogoClick?: (e: React.MouseEvent) => unknown;
+ additionalButtons?: ReactNode;
};
export const SidebarDesktop = ({
activePage: activePageProp,
featureTheme,
isNavButtons,
+ showFeedbackWidget,
onNavTabClick,
+ onLogoClick,
+ additionalButtons,
}: SidebarDesktopProps): ReactElement => {
const router = useRouter();
- const { sidebarExpanded } = useSettingsContext();
+ const { flags, sidebarExpanded, toggleSidebarExpanded, updateFlag } =
+ useSettingsContext();
+ const { logEvent } = useLogContext();
const { isAvailable: isBannerAvailable } = useBanner();
- const activePage = activePageProp || router.asPath || router.pathname;
+ const { open: openSpotlight } = useSpotlight();
+ const { openNewSquad } = useSquadNavigation();
+ const { isLoggedIn, user } = useAuthContext();
+ useRecentPagesTracker();
+ const activePage = activePageProp || router.asPath || router.pathname || '';
+ const isUserProfileActive =
+ !!user?.username && activePage.includes(`/${user.username}`);
+ const isFeedPage = activePage.includes('/feeds/');
+
+ const resolvedCategory = useMemo((): SidebarSelectedCategory => {
+ if (isFeedPage) {
+ return SidebarSelectedCategory.Main;
+ }
+ if (isUserProfileActive) {
+ return SidebarSelectedCategory.Profile;
+ }
+ const activeCategory = getSidebarCategoryForPath(activePage);
+ if (activeCategory === SidebarSelectedCategory.Main) {
+ return normalizeSidebarCategory(flags?.sidebarSelectedCategory);
+ }
+ return activeCategory;
+ }, [
+ activePage,
+ flags?.sidebarSelectedCategory,
+ isFeedPage,
+ isUserProfileActive,
+ ]);
+
+ // `pendingCategory` is an optimistic override applied the moment a
+ // rail icon is clicked, so the panel switches instantly even though
+ // `router.push` is async. Without it we'd flicker:
+ // 1. click Profile -> setState(Profile)
+ // 2. settings flag updates synchronously -> re-render
+ // 3. URL hasn't navigated yet, so `getSidebarCategoryForPath` still
+ // returns the OLD route's category and would clobber Profile
+ // 4. URL eventually updates -> snaps back to Profile
+ // The override stays in place until the resolved category catches up
+ // (URL navigated AND/OR flag landed), then it's cleared so future
+ // route changes from elsewhere (back button, deep link) take over.
+ const [pendingCategory, setPendingCategory] =
+ useState
(null);
+ const selectedCategory = pendingCategory ?? resolvedCategory;
+
+ useEffect(() => {
+ if (pendingCategory !== null && pendingCategory === resolvedCategory) {
+ setPendingCategory(null);
+ }
+ }, [pendingCategory, resolvedCategory]);
const defaultRenderSectionProps = useMemo(
() => ({
- sidebarExpanded,
- shouldShowLabel: sidebarExpanded,
+ sidebarExpanded: true,
+ shouldShowLabel: true,
activePage,
}),
- [sidebarExpanded, activePage],
+ [activePage],
+ );
+
+ const getCategoryDefaultPath = useCallback(
+ (category: SidebarSelectedCategory): string | null => {
+ if (category === SidebarSelectedCategory.Profile) {
+ return user?.username ? `${webappUrl}${user.username}` : null;
+ }
+ if (category === SidebarSelectedCategory.Settings) {
+ return settingsDefaultPath;
+ }
+ return (
+ sidebarCategories.find((entry) => entry.id === category)?.defaultPath ??
+ null
+ );
+ },
+ [user?.username],
+ );
+
+ const onSelectCategory = useCallback(
+ (category: SidebarSelectedCategory) => {
+ // Snap the panel immediately so the click feels responsive and so
+ // the in-flight router.push doesn't race the resolver back to the
+ // old URL's category.
+ setPendingCategory(category);
+
+ // GameCenter and Settings are pure navigations, not persistent
+ // panel states: their landing pages drive the highlight via
+ // `getSidebarCategoryForPath`. Persisting them to the server flag
+ // would be normalised back to Main on the next read anyway.
+ if (
+ category !== SidebarSelectedCategory.GameCenter &&
+ category !== SidebarSelectedCategory.Settings
+ ) {
+ updateFlag(SidebarSettingsFlags.SelectedCategory, category);
+ }
+
+ const targetPath = getCategoryDefaultPath(category);
+ if (!targetPath) {
+ return;
+ }
+ // `defaultPath` is an absolute URL (uses `webappUrl`), but
+ // `activePage` is a router asPath like "/squads?x=1". Compare on
+ // pathname only so we don't push when already there (the dummy
+ // origin handles both absolute + relative targets).
+ const targetPathname = new URL(targetPath, 'http://_').pathname;
+ const currentPathname = activePage.split('?')[0];
+ if (targetPathname !== currentPathname) {
+ // `Promise.resolve` wraps the result so we can still attach
+ // `.catch` even when consumers (e.g. the next/router test
+ // mock) return `undefined` from `push` instead of a real
+ // Promise — otherwise tests that fire-click a category in
+ // the rail crash with "Cannot read .catch of undefined".
+ Promise.resolve(router.push(targetPath)).catch(() => undefined);
+ }
+ },
+ [activePage, getCategoryDefaultPath, router, updateFlag],
+ );
+
+ // Warm the route on hover so the click-to-page transition feels
+ // instant. Next.js router.prefetch is a no-op in development unless
+ // explicitly enabled, but in production it primes the JS chunk + RSC
+ // payload for the destination so navigation skips the network wait.
+ const onPrefetchCategory = useCallback(
+ (category: SidebarSelectedCategory) => {
+ const targetPath = getCategoryDefaultPath(category);
+ if (!targetPath) {
+ return;
+ }
+ router.prefetch(targetPath).catch(() => undefined);
+ },
+ [getCategoryDefaultPath, router],
);
+ const onToggleExpanded = useCallback(() => {
+ logEvent({
+ event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`,
+ });
+ toggleSidebarExpanded();
+ }, [logEvent, sidebarExpanded, toggleSidebarExpanded]);
+
+ const renderCategorySection = (
+ category: SidebarSelectedCategory,
+ ): ReactElement => {
+ if (category === SidebarSelectedCategory.Squads) {
+ return (
+
+ );
+ }
+
+ if (category === SidebarSelectedCategory.Saved) {
+ return (
+
+ );
+ }
+
+ if (category === SidebarSelectedCategory.Discover) {
+ return (
+
+ );
+ }
+
+ if (category === SidebarSelectedCategory.Settings) {
+ return (
+
+ );
+ }
+
+ if (category === SidebarSelectedCategory.GameCenter) {
+ return (
+
+ );
+ }
+
+ if (category === SidebarSelectedCategory.Profile) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+ };
+
+ const renderSelectedSection = (): ReactElement =>
+ renderCategorySection(selectedCategory);
+
+ const selectedLabel = sidebarCategories.find(
+ (category) => category.id === selectedCategory,
+ )?.label;
+ const isSettingsSelected =
+ selectedCategory === SidebarSelectedCategory.Settings;
+ const isHomePanel = selectedCategory === SidebarSelectedCategory.Main;
+ const isSquadsPanel = selectedCategory === SidebarSelectedCategory.Squads;
+ // Anything that's not the personalised Home panel gets the slim
+ // generic header treatment (title left, optional add + close right) and
+ // skips the profile chrome, create-post, and feedback widget.
+ const isUtilityPanelSelected = !isHomePanel;
+ const utilityPanelTitle = isSettingsSelected
+ ? 'Settings'
+ : selectedLabel ?? '';
+
return (
-
-
-
- {/* Primary Action */}
-
+
+
+
+
+
+
+
+
+
+
+
+ {shortcutKeys.map((key) => (
+
+ {key}
+
+ ))}
+
+
+
+
+
+ {sidebarCategories.map((category) => {
+ if (
+ category.id === SidebarSelectedCategory.Profile &&
+ !isLoggedIn
+ ) {
+ return null;
+ }
+
+ const isSelected = selectedCategory === category.id;
+
+ return (
+
+ {category.id === SidebarSelectedCategory.Saved &&
+ isLoggedIn && }
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Single toggle button that physically slides between two anchor
+ points instead of swapping two separate elements:
+
+ - Open: parked at the right edge of the panel header, ghost-style
+ (no border, no background) — the original in-panel close
+ button position.
+ - Closed: parked at the rail-panel boundary, eye-level with the
+ daily.dev logo, with border + background + shadow so it reads
+ as a discoverable "open me" handle on the bare rail.
+
+ Wrapping the button in a `top-6 h-10 flex items-center` shell
+ anchors its vertical center to the logo row in both states. The
+ icon is the same `SidebarArrowLeft` glyph in both states; we
+ just rotate 180° + transition `left` / colors / shadow together
+ in lockstep with the sidebar's own width transition (300ms).
+ */}
+
+
+
-
+
+
+
+
+
-
+
+
+ {renderSelectedSection()}
+
+
- {/* Help guide — pinned to sidebar bottom (renders only when a marketingCTA is targeted) */}
-
+ {!isUtilityPanelSelected && }
+ {showFeedbackWidget && !isUtilityPanelSelected && (
+
+
+
+ )}
+
);
};
diff --git a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx
new file mode 100644
index 00000000000..edcbb31e305
--- /dev/null
+++ b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx
@@ -0,0 +1,267 @@
+import type { MouseEvent, ReactElement, ReactNode } from 'react';
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useReadingStreak } from '../../hooks/streaks';
+import { useLogContext } from '../../contexts/LogContext';
+import { walletUrl, isTesting } from '../../lib/constants';
+import { largeNumberFormat } from '../../lib';
+import { formatCurrency } from '../../lib/utils';
+import { LogEvent } from '../../lib/log';
+import Link from '../utilities/Link';
+import { Tooltip } from '../tooltip/Tooltip';
+import { SimpleTooltip } from '../tooltips';
+import type { TooltipProps } from '../tooltips/BaseTooltip';
+import { IconSize } from '../Icon';
+import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons';
+import { Typography, TypographyType } from '../typography/Typography';
+import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup';
+import type { UserStreak } from '../../graphql/users';
+
+// Tight, equal slot. `gap-1` (4px) keeps the icon hugging the value
+// the same way the streak slot already did. `px-1.5` shaves the side
+// inset so a 3-digit value (e.g. `999`) plus the 16px icon frame
+// still fits comfortably in the panel's 240px width without truncating.
+const slotClass =
+ 'focus-outline group flex flex-1 items-center justify-center gap-1 px-1.5 py-1.5 transition-colors hover:bg-surface-hover min-w-0';
+// `tabular-nums` keeps 1- / 2- / 3-digit values aligned so the row
+// doesn't visually jitter as the streak counts up.
+const valueClass = 'text-text-primary tabular-nums';
+// Wrap each icon in a fixed 16px square so the streak / rep / cores
+// glyphs share the same visual footprint. Sized down from 20px so the
+// flame-filled streak no longer reads as larger than the lightning
+// bolt + cores diamond, which only fill ~60% of their viewBoxes.
+const iconBoxClass = 'flex size-4 shrink-0 items-center justify-center';
+
+type StatSlotProps = {
+ ariaLabel: string;
+ icon: ReactNode;
+ // `largeNumberFormat` can return `null` for nullish inputs; accept it
+ // here so callers don't need to coerce before passing.
+ value: string | number | null;
+ href?: string;
+ onClick?: (event: MouseEvent) => void;
+ id?: string;
+};
+
+// Renders a single inline stat. When `href` is provided the slot becomes a
+// link; with `onClick` it becomes a button; otherwise it renders as a static
+// span so the surrounding card's outer link still owns the click target.
+const StatSlot = ({
+ ariaLabel,
+ icon,
+ value,
+ href,
+ onClick,
+ id,
+}: StatSlotProps): ReactElement => {
+ const inner = (
+ <>
+ {icon}
+
+ {value}
+
+ >
+ );
+
+ if (onClick) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ if (!href) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ return (
+
+
+ {inner}
+
+
+ );
+};
+
+const dividerClass = 'w-px self-stretch bg-border-subtlest-quaternary';
+
+// Both the hover hint and the click-open popup share `SimpleTooltip` so Tippy
+// keeps the same reference element across toggles. Without this, swapping
+// between a Radix `Tooltip` (hover) and Tippy `SimpleTooltip` (popup) on click
+// remounts the trigger and Tippy can mis-position the popup so it overlaps
+// the streak button instead of opening below it.
+type StreakPopupTooltipProps = Pick & {
+ streak: UserStreak;
+ shouldShowStreaks: boolean;
+ onClose: () => void;
+};
+
+const StreakPopupTooltip = ({
+ children,
+ streak,
+ shouldShowStreaks,
+ onClose,
+}: StreakPopupTooltipProps): ReactElement => (
+ document.body}
+ zIndex={1000}
+ offset={[0, 8]}
+ container={{
+ paddingClassName: 'p-0',
+ bgClassName: 'bg-accent-pepper-subtlest',
+ textClassName: 'text-text-primary typo-callout',
+ className: 'border border-border-subtlest-tertiary rounded-16',
+ }}
+ content={ }
+ onClickOutside={onClose}
+ >
+ {children}
+
+);
+
+const StreakHintTooltip = ({
+ children,
+ content,
+}: Pick): ReactElement => (
+
+ {children}
+
+);
+
+// Compact, divided inline stats strip rendered under the user identity row in
+// the dedicated home panel. The values stay visible at all sizes (including
+// `0`) and the slots share an equal-width row so the strip reads at a glance.
+export const SidebarHeaderStats = (): ReactElement | null => {
+ const { user } = useAuthContext();
+ const { streak, isStreaksEnabled } = useReadingStreak();
+ const { logEvent } = useLogContext();
+ const [isStreaksOpen, setIsStreaksOpen] = useState(false);
+
+ const handleStreakClick = useCallback(() => {
+ setIsStreaksOpen((open) => {
+ const next = !open;
+ if (next) {
+ logEvent({ event_name: LogEvent.OpenStreaks });
+ }
+ return next;
+ });
+ }, [logEvent]);
+
+ if (!user) {
+ return null;
+ }
+
+ const reputation = user.reputation ?? 0;
+ const balance = user.balance?.amount ?? 0;
+ const showStreak = isStreaksEnabled;
+ const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 });
+ const streakValue = streak?.current ?? 0;
+
+ const streakSlot = (
+
);
@@ -165,7 +172,7 @@ export const getLayout = (
props: ProfileLayoutProps,
): ReactNode =>
getFooterNavBarLayout(
- getMainLayout(