diff --git a/packages/extension/src/newtab/ExtensionTopBanners.tsx b/packages/extension/src/newtab/ExtensionTopBanners.tsx new file mode 100644 index 00000000000..99740082221 --- /dev/null +++ b/packages/extension/src/newtab/ExtensionTopBanners.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { TopHero } from '@dailydotdev/shared/src/components/marketing/banners/HeroBottomBanner'; +import { useReadingReminderHero } from '@dailydotdev/shared/src/hooks/notifications/useReadingReminderHero'; + +export const ExtensionTopBanners = (): ReactElement | null => { + // The extension's top hero row is the only place this card appears + // on the new tab, so we evaluate the reminder regardless of viewport + // (the webapp-only `requireMobile` heuristic would hide it on desktop + // new tabs, which is where the extension lives). + const reminder = useReadingReminderHero({ requireMobile: false }); + + if (!reminder.shouldShow) { + return null; + } + + return ( +
+ { + reminder.onEnable(); + }} + onClose={() => { + reminder.onDismiss(); + }} + /> +
+ ); +}; diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index 20f32e148d7..d42e67b4d40 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -31,6 +31,7 @@ import { isFocusActiveAt } from '@dailydotdev/shared/src/features/customizeNewTa import { normaliseNewTabMode } from '@dailydotdev/shared/src/features/customizeNewTab/lib/newTabMode'; import { DndBanner } from '@dailydotdev/shared/src/components/DndBanner'; import ShortcutLinks from './ShortcutLinks/ShortcutLinks'; +import { ExtensionTopBanners } from './ExtensionTopBanners'; import { CompanionPopupButton } from '../companion/CompanionPopupButton'; import { useCompanionSettings } from '../companion/useCompanionSettings'; import { getDefaultLink } from './dnd'; @@ -198,6 +199,7 @@ const MainFeedPageInner = ({ additionalButtons={ !loadingUser && !optOutCompanion && } + topBanner={} > , 'className' | 'onClick' | 'icon'> ->) => ( - -); - const bookmarkSortOptions = [ { label: 'Newest first', value: BookmarkSort.TimeDesc }, { label: 'Oldest first', value: BookmarkSort.TimeAsc }, @@ -103,6 +93,8 @@ export default function BookmarkFeedLayout({ DEFAULT_BOOKMARK_SORT_INDEX, ); const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const isSearchResults = !!searchQuery; const isFolderPage = !!folder || isReminderOnly; const listId = folder?.id; @@ -190,54 +182,109 @@ export default function BookmarkFeedLayout({ return null; } + const sortDropdown = !isSearchResults && ( + } + iconOnly + selectedIndex={selectedSort} + options={bookmarkSortOptionLabels} + onChange={(_, index) => setSelectedSort(index)} + buttonVariant={isV2Laptop ? ButtonVariant.Tertiary : ButtonVariant.Float} + buttonSize={isV2Laptop ? ButtonSize.Small : ButtonSize.Medium} + drawerProps={{ displayCloseButton: true }} + /> + ); + const shareButton = !isFolderPage && ( + + ); + const folderMenu = folder && !isReminderOnly && ( + + ); + const headerTitleSlot = isV2Laptop ? ( +
+ + {title} + + {searchChildren && ( +
{searchChildren}
+ )} +
+ ) : ( + title + ); + return ( {children} - - - {title} - - - - {searchChildren} - {!isSearchResults && ( - } - iconOnly - selectedIndex={selectedSort} - options={bookmarkSortOptionLabels} - onChange={(_, index) => setSelectedSort(index)} - buttonVariant={ButtonVariant.Float} - buttonSize={ButtonSize.Medium} - drawerProps={{ displayCloseButton: true }} - /> - )} - {!isFolderPage && ( - } - onClick={() => setShowSharedBookmarks(true)} + {isV2Laptop ? ( + + {sortDropdown} + {shareButton} + {folderMenu} + + ) : ( + <> + + + {title} + + + - {isLaptop ? Share bookmarks : null} - - )} - {folder && !isReminderOnly && ( - - )} - + {searchChildren} + {sortDropdown} + {shareButton} + {folderMenu} + + + )} {showSharedBookmarks && ( ({ readerEligiblePostTypes.has(post.type), [isReaderModalFeatureReady, isReaderModalOn, readerEligiblePostTypes], ); + const { isV2 } = useLayoutVariant(); const { adjustedHeroInsertIndex, shouldShowTopHero, @@ -377,6 +379,9 @@ export default function Feed({ itemCount: items.length, itemsPerRow: virtualizedNumCards, firstSlotOffset: Number(showProfileCompletionCard || showBriefCard), + // Layout v2 hoists the top hero into MainLayout above the floating + // feed card, so the feed must not render or measure it here. + disableTopHero: isV2, }); useMutationSubscription({ diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index dd26b0bca49..01e30d584af 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -84,6 +84,8 @@ import { checkIsExtension } from '../lib/func'; import { useReadingReminderHero } from '../hooks/notifications/useReadingReminderHero'; import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { useReadingReminderVariation } from '../hooks/notifications/useReadingReminderVariation'; +import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; +import { pageHeaderClassName } from './layout/PageHeader'; const FeedExploreHeader = dynamic( () => @@ -237,6 +239,7 @@ export default function MainFeedLayout({ }); const { isCustomDefaultFeed, defaultFeedId } = useCustomDefaultFeed(); const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); const feedVersion = useFeature(feature.feedVersion); const { time, contentCurationFilter } = useSearchContextProvider(); const { @@ -710,61 +713,82 @@ export default function MainFeedLayout({ ); }, [isLaptop, onTabChange, tab]); + // v2 hoists the explore section tabs into the floating card's + // page-header strip (matching the SquadDirectoryLayout pattern). The + // inline FeedExploreComponent is suppressed below to avoid showing + // the same tabs twice. + const showExploreV2PageHeader = isAnyExplore && isV2; + return ( - - {isAnyExplore && } - {isSearchOn && !isSearchPageLaptop && search} - {isSearchOn && isFinder && !isSearchPageLaptop && ( - - )} - {shouldShowReadingReminderOnHomepage && ( - - )} - {isHomePage && ( - - )} - {!isExtension && isHomePage && ( - + <> + {showExploreV2PageHeader && ( +
+ +
)} - {shouldUseCommentFeedLayout ? ( - - } - commentClassName={commentClassName} - /> - ) : ( - feedProps && ( - {chipsNode} - ) : undefined + + {isAnyExplore && !showExploreV2PageHeader && } + {isSearchOn && !isSearchPageLaptop && search} + {isSearchOn && isFinder && !isSearchPageLaptop && ( + + )} + {shouldShowReadingReminderOnHomepage && ( + + )} + {isHomePage && ( + + )} + {!isExtension && isHomePage && ( + + )} + {shouldUseCommentFeedLayout ? ( + } - className={classNames( - shouldUseListFeedLayout && !isFinder && 'laptop:px-6', - )} + commentClassName={commentClassName} /> - ) - )} - {children} - + ) : ( + feedProps && ( + {chipsNode} + ) : undefined + } + className={classNames( + shouldUseListFeedLayout && !isFinder && 'laptop:px-6', + )} + /> + ) + )} + {children} +
+ ); } diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index ef63d4e726a..87ba7ed4d74 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -12,7 +12,12 @@ import type { MainLayoutHeaderProps } from './layout/MainLayoutHeader'; import MainLayoutHeader from './layout/MainLayoutHeader'; import { InAppNotificationElement } from './notifications/InAppNotification'; import { useNotificationContext } from '../contexts/NotificationsContext'; -import { LogEvent, NotificationTarget, TargetType } from '../lib/log'; +import { + LogEvent, + NotificationCtaPlacement, + NotificationTarget, + TargetType, +} from '../lib/log'; import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; @@ -36,6 +41,10 @@ import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; +import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; +import { TopHero } from './marketing/banners/HeroBottomBanner'; +import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; +import { RouteProgressBar } from './RouteProgressBar'; const GoBackHeaderMobile = dynamic( () => @@ -64,6 +73,14 @@ export interface MainLayoutProps canGoBack?: string; hideBackButton?: boolean; hideFeedbackWidget?: boolean; + /** + * Layout v2 only. Rendered above the floating feed card, alongside the + * built-in reading-reminder TopHero. Pages can pass dynamic banners + * (e.g. the extension's onboarding hero row) and the whole strip + * collapses to nothing if neither the reminder nor the banner has + * anything to show. + */ + topBanner?: ReactNode; } export const feeds = Object.values(SharedFeedPage); @@ -81,10 +98,11 @@ function MainLayoutComponent({ onNavTabClick, canGoBack, hideFeedbackWidget = false, + topBanner, }: MainLayoutProps): ReactElement | null { const router = useRouter(); const { logEvent } = useLogContext(); - const { user, isAuthReady, showLogin } = useAuthContext(); + const { user, isAuthReady, isLoggedIn, showLogin } = useAuthContext(); const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); @@ -99,8 +117,33 @@ function MainLayoutComponent({ const isLaptopXL = useViewSize(ViewSize.LaptopXL); const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); + const { isV2 } = useLayoutVariant(); useNotificationParams(); + // v2 hoists the reading-reminder hero out of the in-feed slot and + // renders it here, above the floating card. `itemCount: 0` keeps the + // mid-feed placement disabled in this consumer — the feed still owns + // that one for both layouts. + const { + shouldShowTopHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + onEnableHero: onEnableReadingReminder, + onDismissHero: onDismissReadingReminder, + } = useReadingReminderFeedHero({ + itemCount: 0, + itemsPerRow: 1, + }); + + // The dual-sidebar layout takes ownership of the global header chrome + // (logo + search + user actions) on laptop+ for authenticated users + // (and for extension new tab regardless of auth state). When that's + // the case the global header is hidden, the main content gets the + // floating-card treatment, and the global feedback widget is suppressed + // because the rail provides its own. + const sidebarOwnsHeader = + isV2 && (isLoggedIn || isExtension) && showSidebar && sidebarRendered; + useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { return; @@ -179,7 +222,13 @@ function MainLayoutComponent({ isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -197,35 +246,74 @@ function MainLayoutComponent({ /> )} - + {!sidebarOwnsHeader && ( + + )}
{isAuthReady && showSidebar && ( )} - {children} + {sidebarOwnsHeader ? ( +
+ {shouldShowTopHero && ( + + onEnableReadingReminder(NotificationCtaPlacement.TopHero) + } + onClose={() => + onDismissReadingReminder(NotificationCtaPlacement.TopHero) + } + /> + )} + {topBanner} +
+ + {children} +
+
+ ) : ( + children + )}
- {!hideFeedbackWidget && } + {!hideFeedbackWidget && !sidebarOwnsHeader && }
); } diff --git a/packages/shared/src/components/RouteProgressBar.module.css b/packages/shared/src/components/RouteProgressBar.module.css new file mode 100644 index 00000000000..28b88b982bb --- /dev/null +++ b/packages/shared/src/components/RouteProgressBar.module.css @@ -0,0 +1,12 @@ +@keyframes route-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } +} + +.bar { + animation: route-progress-slide 1s cubic-bezier(0.65, 0, 0.35, 1) infinite; +} diff --git a/packages/shared/src/components/RouteProgressBar.tsx b/packages/shared/src/components/RouteProgressBar.tsx new file mode 100644 index 00000000000..e010e434ce9 --- /dev/null +++ b/packages/shared/src/components/RouteProgressBar.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import styles from './RouteProgressBar.module.css'; + +interface RouteProgressBarProps { + className?: string; +} + +/** + * Thin indeterminate progress bar rendered at the top of the v2 floating + * card while Next.js is between `routeChangeStart` and `routeChangeComplete`. + * + * Without this, the v2 sidebar updates instantly on rail-icon click (the + * optimistic `pendingCategory` swap) but the page content can stay on the + * old route for a noticeable beat while the destination chunk loads — + * making the two halves of the screen feel out of sync. The progress bar + * signals "we heard you, the page is loading" so the gap reads as + * intentional rather than broken. + * + * The bar is absolute / pointer-events-none, so consumers just drop it + * inside their already-positioned container (e.g. the floating-card + * wrapper) and it floats at the top edge without affecting layout. + */ +export const RouteProgressBar = ({ + className, +}: RouteProgressBarProps): ReactElement | null => { + const router = useRouter(); + const [isRouteChanging, setIsRouteChanging] = useState(false); + + useEffect(() => { + const handleStart = () => setIsRouteChanging(true); + const handleEnd = () => setIsRouteChanging(false); + + router.events.on('routeChangeStart', handleStart); + router.events.on('routeChangeComplete', handleEnd); + router.events.on('routeChangeError', handleEnd); + + return () => { + router.events.off('routeChangeStart', handleStart); + router.events.off('routeChangeComplete', handleEnd); + router.events.off('routeChangeError', handleEnd); + }; + }, [router.events]); + + if (!isRouteChanging) { + return null; + } + + return ( +
+
+
+ ); +}; diff --git a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx index ed12993f232..51595a9191b 100644 --- a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx +++ b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx @@ -14,14 +14,17 @@ import { DropdownMenuTrigger, } from '../dropdown/DropdownMenu'; import { Button } from '../buttons/Button'; +import type { ButtonProps } from '../buttons/Button'; import type { MenuItemProps } from '../dropdown/common'; interface BookmarkFolderContextMenuProps { folder: BookmarkFolder; + buttonProps?: Pick, 'className' | 'size' | 'variant'>; } export const BookmarkFolderContextMenu = ({ folder, + buttonProps, }: BookmarkFolderContextMenuProps): ReactElement => { const { openModal, closeModal } = useLazyModal(); const { showPrompt } = usePrompt(); @@ -68,9 +71,9 @@ export const BookmarkFolderContextMenu = ({ + + {isSidebar && ( + + )} +
+ ); + } + return ( diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx new file mode 100644 index 00000000000..f2410b0f6d6 --- /dev/null +++ b/packages/shared/src/components/layout/PageHeader.tsx @@ -0,0 +1,38 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +// `min-h-14` locks the strip to a Small-button + py-3 height so the +// header reads at a consistent 56px regardless of action contents. +export const pageHeaderClassName = + 'flex min-h-14 w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3'; + +export interface PageHeaderProps { + title?: ReactNode; + children?: ReactNode; + className?: string; +} + +export const PageHeader = ({ + title, + children, + className, +}: PageHeaderProps): ReactElement => ( +
+ {title !== undefined && + (typeof title === 'string' ? ( + + {title} + + ) : ( +
+ {title} +
+ ))} + {children !== undefined && ( +
+ {children} +
+ )} +
+); diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index b39c2526f8e..2a7caf75707 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -50,6 +50,7 @@ import { downloadBrowserExtension } from '../../lib/constants'; import { anchorDefaultRel } from '../../lib/strings'; import ConditionalWrapper from '../ConditionalWrapper'; import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; +import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; type State = [T, Dispatch>]; @@ -94,6 +95,8 @@ export const SearchControlHeader = ({ const { isUpvoted, isSortableFeed } = useFeedName({ feedName }); const isLaptop = useViewSize(ViewSize.Laptop); const isMobile = useViewSize(ViewSize.MobileL); + const { isV2 } = useLayoutVariant(); + const isV2Strip = isV2; const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const browserName = getCurrentBrowserName(); @@ -128,13 +131,18 @@ export const SearchControlHeader = ({ className: { label: 'hidden', chevron: 'hidden', - button: '!px-1', + // V2 ghost styling is applied via descendant selectors on the + // wrapping strip (see `v2CompactButtons`); the dropdown's own + // button only needs the legacy tight horizontal padding for the + // non-v2 layout. + button: isV2Strip ? undefined : '!px-1', container: 'flex', }, shouldIndicateSelected: true, - buttonSize: isMobile ? ButtonSize.Small : ButtonSize.Medium, + buttonSize: isMobile || isV2Strip ? ButtonSize.Small : ButtonSize.Medium, iconOnly: true, - buttonVariant: isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary, + buttonVariant: + isV2Strip || !isLaptop ? ButtonVariant.Tertiary : ButtonVariant.Float, }; const shouldShowInstallExtensionPrompt = @@ -169,13 +177,14 @@ export const SearchControlHeader = ({ ); + const dropdownIconSize = isV2Strip ? IconSize.XSmall : IconSize.Medium; const primaryActions = [ hasFeedActions && , isUpvoted ? ( } + icon={} selectedIndex={selectedPeriod} options={periodTexts} onChange={(_, index) => setSelectedPeriod(index)} @@ -185,7 +194,7 @@ export const SearchControlHeader = ({ } + icon={} selectedIndex={selectedAlgo} options={algorithmsList} onChange={(_, index) => setSelectedAlgo(index)} @@ -208,6 +217,14 @@ export const SearchControlHeader = ({ const actions = primaryActions.filter(Boolean); const sideActions = secondaryActions.filter(Boolean); + // In v2 the FeedContainer wraps these actions inside its own + // strip (with compact-button descendant selectors), so we + // skip the local `
` wrapper here and just return the action + // contents inline. Control + mobile paths keep the legacy structure. + if (isV2Strip) { + return <>{actions}; + } + return ( -
+
{!!chips &&
{chips}
}
{actions}
{sideActions.length > 0 && (
{sideActions}
)} -
+
); }; diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 1122dc10e27..9afc5d799ec 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,8 +13,15 @@ import { webappUrl } from '../../lib/constants'; import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; +import { IconSize } from '../Icon'; -function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { +function NotificationsBell({ + compact, + rail, +}: { + compact?: boolean; + rail?: boolean; +}): ReactElement { const router = useRouter(); const atNotificationsPage = router.pathname === notificationsUrl; const { logEvent } = useLogContext(); @@ -31,6 +38,39 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option; + if (rail) { + return ( + + + + ); + } + return (
diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx index 8865a4d3344..e84ccb5ad82 100644 --- a/packages/shared/src/components/quest/QuestButton.tsx +++ b/packages/shared/src/components/quest/QuestButton.tsx @@ -507,6 +507,7 @@ const QuestLevelFireworkLayer = ({ interface QuestButtonProps { compact?: boolean; + panelOnly?: boolean; } interface QuestDropdownPanelProps { @@ -659,6 +660,7 @@ const QuestDropdownPanel = ({ export const QuestButton = ({ compact = false, + panelOnly = false, }: QuestButtonProps): ReactElement => { const router = useRouter(); const { optOutLevelSystem } = useSettingsContext(); @@ -1210,6 +1212,44 @@ export const QuestButton = ({ }; }, [clearClaimedStampTimers, clearProgressTimers]); + if (panelOnly) { + return ( + <> +
+ +
+ {rewardFlightLayers.map((layer) => ( + handleRewardFlightLayerDone(layer.claimRotationId)} + /> + ))} + {levelFireworkParticles.length > 0 && ( + + )} + + ); + } + return ( <> diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx new file mode 100644 index 00000000000..7ea5c433697 --- /dev/null +++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { Typography, TypographyType } from '../typography/Typography'; +import { Nav, SidebarScrollWrapper } from './common'; + +export interface RailHoverPanelProps { + title: string; + children: ReactNode; + className?: string; +} + +const floatingListSpacingOverrides = '[&_li.mx-3]:!mx-2'; + +export const RailHoverPanel = ({ + title, + children, + className, +}: RailHoverPanelProps) => ( +
+
+ + {title} + +
+ + + +
+); diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx index 9bcb2a90861..edd36f7b135 100644 --- a/packages/shared/src/components/sidebar/Section.tsx +++ b/packages/shared/src/components/sidebar/Section.tsx @@ -76,7 +76,11 @@ export function Section({ {/* Header content shown when sidebar is expanded */}
diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx index 347ed949036..ea4c4fcdb52 100644 --- a/packages/shared/src/components/sidebar/Sidebar.tsx +++ b/packages/shared/src/components/sidebar/Sidebar.tsx @@ -1,8 +1,9 @@ -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'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; +import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; import { isExtension } from '../../lib/func'; const SidebarTablet = dynamic(() => @@ -15,16 +16,25 @@ const SidebarDesktop = dynamic(() => (mod) => mod.SidebarDesktop, ), ); +const SidebarDesktopV2 = dynamic(() => + import(/* webpackChunkName: "sidebarDesktopV2" */ './SidebarDesktopV2').then( + (mod) => mod.SidebarDesktopV2, + ), +); 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, @@ -32,6 +42,7 @@ export const Sidebar = ({ const isLaptop = useViewSize(ViewSize.Laptop); const isTablet = useViewSize(ViewSize.Tablet); const featureTheme = useFeatureTheme(); + const { isV2 } = useLayoutVariant(); if (!isLaptop && isTablet) { return ( @@ -44,10 +55,21 @@ export const Sidebar = ({ } if (isLaptop) { + if (isV2) { + return ( + + ); + } return ( ReactElement; + defaultPath?: string; +}; + +const sidebarCategories: SidebarCategoryConfig[] = [ + { + id: SidebarCategory.Main, + label: 'Home', + defaultPath: webappUrl, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.Squads, + label: 'Squads', + defaultPath: `${webappUrl}squads/discover`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.Discover, + label: 'Discover', + defaultPath: `${webappUrl}tags`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.Saved, + label: 'Saved', + defaultPath: `${webappUrl}bookmarks`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.GameCenter, + label: 'Game Center', + defaultPath: `${webappUrl}game-center`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.Profile, + label: 'Profile', + icon: (active) => ( + + ), + }, +]; + +const discoverPathFragments = ['/tags', '/sources', '/users', '/discussed']; +const profilePathFragments = [ + '/analytics', + '/jobs', + '/settings/customization/devcard', + '/wallet', +]; + +const getSidebarCategoryForPath = (activePage: string): SidebarCategoryId => { + if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) { + return SidebarCategory.Saved; + } + if (activePage.includes('/squads')) { + return SidebarCategory.Squads; + } + if (activePage.includes('/settings')) { + return SidebarCategory.Settings; + } + if (activePage.includes('/game-center')) { + return SidebarCategory.GameCenter; + } + if (discoverPathFragments.some((path) => activePage.includes(path))) { + return SidebarCategory.Discover; + } + if (profilePathFragments.some((path) => activePage.includes(path))) { + return SidebarCategory.Profile; + } + return SidebarCategory.Main; +}; + +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 global-header layout. With the dual-sidebar there's +// no top chrome to clip against, so a snug override re-centers tooltips +// with their triggers. +const RAIL_TOOLTIP_COLLISION_PADDING = 4; + +interface RailHoverCardProps { + label: string; + children: ReactNode; + panel: ReactElement; + enabled?: boolean; + alignOffset?: number; +} + +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 ( + <> + + + + {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 SidebarDesktopV2Props = { + activePage?: string; + featureTheme?: { + logo?: string; + logoText?: string; + }; + isNavButtons?: boolean; + showFeedbackWidget?: boolean; + onNavTabClick?: (tab: string) => void; + onLogoClick?: (e: React.MouseEvent) => unknown; + additionalButtons?: ReactNode; +}; + +export const SidebarDesktopV2 = ({ + activePage: activePageProp, + featureTheme, + isNavButtons, + showFeedbackWidget, + onNavTabClick, + onLogoClick, + additionalButtons, +}: SidebarDesktopV2Props): ReactElement => { + const router = useRouter(); + const { + sidebarExpanded, + toggleSidebarExpanded, + loadedSettings, + optOutQuestSystem, + } = useSettingsContext(); + const { logEvent } = useLogContext(); + const { isAvailable: isBannerAvailable } = useBanner(); + const { open: openSpotlight } = useSpotlight(); + const { openNewSquad } = useSquadNavigation(); + const { isLoggedIn, user } = useAuthContext(); + const activePage = activePageProp || router.asPath || router.pathname || ''; + const isUserProfileActive = + !!user?.username && activePage.includes(`/${user.username}`); + const isFeedPage = activePage.includes('/feeds/'); + + const resolvedCategory = useMemo((): SidebarCategoryId => { + if (isFeedPage) { + return SidebarCategory.Main; + } + if (isUserProfileActive) { + return SidebarCategory.Profile; + } + return getSidebarCategoryForPath(activePage); + }, [activePage, isFeedPage, isUserProfileActive]); + + // Optimistic override so a rail click feels instant even when + // router.push is async. Cleared once the URL catches up. + const [pendingCategory, setPendingCategory] = + useState(null); + const selectedCategory = pendingCategory ?? resolvedCategory; + + useEffect(() => { + if (pendingCategory !== null && pendingCategory === resolvedCategory) { + setPendingCategory(null); + } + }, [pendingCategory, resolvedCategory]); + + const defaultRenderSectionProps = useMemo( + () => ({ + sidebarExpanded: true, + shouldShowLabel: true, + activePage, + }), + [activePage], + ); + + const getCategoryDefaultPath = useCallback( + (category: SidebarCategoryId): string | null => { + if (category === SidebarCategory.Profile) { + return user?.username ? `${webappUrl}${user.username}` : null; + } + if (category === SidebarCategory.Settings) { + return settingsDefaultPath; + } + return ( + sidebarCategories.find((entry) => entry.id === category)?.defaultPath ?? + null + ); + }, + [user?.username], + ); + + const onSelectCategory = useCallback( + (category: SidebarCategoryId) => { + setPendingCategory(category); + + const targetPath = getCategoryDefaultPath(category); + if (!targetPath) { + return; + } + const targetPathname = new URL(targetPath, 'http://_').pathname; + const currentPathname = activePage.split('?')[0]; + if (targetPathname !== currentPathname) { + // `Promise.resolve` wraps the call so `.catch` still works when + // the next/router test mock returns `undefined` from `push`. + Promise.resolve(router.push(targetPath)).catch(() => undefined); + } + }, + [activePage, getCategoryDefaultPath, router], + ); + + const onPrefetchCategory = useCallback( + (category: SidebarCategoryId) => { + 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: SidebarCategoryId): ReactElement => { + if (category === SidebarCategory.Squads) { + return ( + + ); + } + if (category === SidebarCategory.Saved) { + return ( + + ); + } + if (category === SidebarCategory.Discover) { + return ( + + ); + } + if (category === SidebarCategory.Settings) { + return ( + + ); + } + if (category === SidebarCategory.GameCenter) { + const canRenderQuestPanel = + isLoggedIn && loadedSettings && !optOutQuestSystem; + return ( +
+ {canRenderQuestPanel ? ( + + ) : ( + + + Open Game Center + + + )} +
+ ); + } + if (category === SidebarCategory.Profile) { + return ( + <> +
+ +
+ + + ); + } + + return ( + <> + + + + ); + }; + + const renderSelectedSection = (): ReactElement => + renderCategorySection(selectedCategory); + + const selectedLabel = sidebarCategories.find( + (category) => category.id === selectedCategory, + )?.label; + const isSettingsSelected = selectedCategory === SidebarCategory.Settings; + const isHomePanel = selectedCategory === SidebarCategory.Main; + const isSquadsPanel = selectedCategory === SidebarCategory.Squads; + const isUtilityPanelSelected = !isHomePanel; + const utilityPanelTitle = isSettingsSelected + ? 'Settings' + : selectedLabel ?? ''; + + return ( + + + + + {/* + Slide-between-anchors toggle button. Two positions: + - Open (panel right edge): ghost, no border/bg/shadow + - Closed (rail/panel boundary): bordered chip with shadow + Same SidebarArrowLeft glyph, rotated 180° when closed. + */} + + + + + + + ); +}; diff --git a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx new file mode 100644 index 00000000000..72a3fd8b2fe --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx @@ -0,0 +1,236 @@ +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'; + +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'; +const valueClass = 'text-text-primary tabular-nums'; +const iconBoxClass = 'flex size-4 shrink-0 items-center justify-center'; + +type StatSlotProps = { + ariaLabel: string; + icon: ReactNode; + value: string | number | null; + href?: string; + onClick?: (event: MouseEvent) => void; + id?: string; +}; + +const StatSlot = ({ + ariaLabel, + icon, + value, + href, + onClick, + id, +}: StatSlotProps): ReactElement => { + const inner = ( + <> + {icon} + + {value} + + + ); + + if (onClick) { + return ( + + ); + } + + if (!href) { + return ( + + {inner} + + ); + } + + return ( + + + {inner} + + + ); +}; + +const dividerClass = 'w-px self-stretch bg-border-subtlest-quaternary'; + +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} + +); + +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 = ( + + + + } + value={streakValue} + onClick={streak ? handleStreakClick : undefined} + /> + ); + + return ( +
+ {showStreak && ( + <> + {streak && isStreaksOpen ? ( + setIsStreaksOpen(false)} + > + {streakSlot} + + ) : ( + + {streakSlot} + + )} + + + )} + + + + + } + value={largeNumberFormat(reputation)} + /> + + + + Wallet +
+ {preciseBalance} Cores + + } + side="bottom" + > + + +
+ } + value={largeNumberFormat(balance)} + href={walletUrl} + /> + +
+ ); +}; diff --git a/packages/shared/src/components/sidebar/SidebarItem.tsx b/packages/shared/src/components/sidebar/SidebarItem.tsx index c894127b394..76ffcbd76cd 100644 --- a/packages/shared/src/components/sidebar/SidebarItem.tsx +++ b/packages/shared/src/components/sidebar/SidebarItem.tsx @@ -8,6 +8,7 @@ import { ItemInner, NavItem } from './common'; import AuthContext from '../../contexts/AuthContext'; import type { SidebarSectionProps } from './sections/common'; import { SimpleTooltip } from '../tooltips'; +import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; type SidebarItemProps = Pick< SidebarSectionProps, @@ -23,6 +24,7 @@ export const SidebarItem = ({ shouldShowLabel, }: SidebarItemProps): ReactElement => { const { user, showLogin } = useContext(AuthContext); + const { isV2 } = useLayoutVariant(); const isActive = item.active || item.path === activePage; const isCollapsed = !shouldShowLabel; @@ -33,7 +35,7 @@ export const SidebarItem = ({ color={item.color} disableDefaultBackground={item.disableDefaultBackground} className={classNames( - 'mx-1 rounded-10', + isV2 ? 'mx-3 rounded-10' : 'mx-1 rounded-10', item.itemClassName, isCollapsed && 'justify-center', )} diff --git a/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx b/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx new file mode 100644 index 00000000000..c26c42259b6 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarProfileCompletion.tsx @@ -0,0 +1,87 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import ProgressCircle from '../ProgressCircle'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; +import { IconSize } from '../Icon'; +import { ClearIcon } from '../icons'; +import Link from '../utilities/Link'; +import { anchorDefaultRel } from '../../lib/strings'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; +import { + formatCompletionDescription, + getCompletionItems, +} from '../../lib/profileCompletion'; + +export const SidebarProfileCompletion = (): ReactElement | null => { + const { user } = useAuthContext(); + const { showIndicator, dismissIndicator } = useProfileCompletionIndicator(); + const profileCompletion = user?.profileCompletion; + + const items = useMemo( + () => (profileCompletion ? getCompletionItems(profileCompletion) : []), + [profileCompletion], + ); + const incompleteItems = useMemo( + () => items.filter((item) => !item.completed), + [items], + ); + const description = useMemo( + () => formatCompletionDescription(incompleteItems), + [incompleteItems], + ); + + if (!showIndicator || !profileCompletion) { + return null; + } + + const progress = profileCompletion.percentage ?? 0; + const redirectPath = incompleteItems[0]?.redirectPath; + + return ( + + ); +}; diff --git a/packages/shared/src/components/sidebar/sections/MainSection.tsx b/packages/shared/src/components/sidebar/sections/MainSection.tsx index 54b2f899a69..d382743694d 100644 --- a/packages/shared/src/components/sidebar/sections/MainSection.tsx +++ b/packages/shared/src/components/sidebar/sections/MainSection.tsx @@ -9,6 +9,7 @@ import { HomeIcon, HotIcon, JoystickIcon, + MagicIcon, SquadIcon, MegaphoneIcon, YearInReviewIcon, @@ -30,6 +31,7 @@ import { featurePlusApiLanding, featureYearInReview, } from '../../../lib/featureManagement'; +import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; import { useQuestDashboard } from '../../../hooks/useQuestDashboard'; import { Typography, TypographyColor } from '../../typography/Typography'; @@ -40,6 +42,7 @@ export const MainSection = ({ }: SidebarSectionProps): ReactElement => { const { user, isLoggedIn } = useAuthContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); + const { isV2 } = useLayoutVariant(); const isPlus = user?.isPlus; const { value: isApiLanding } = useConditionalFeature({ feature: featurePlusApiLanding, @@ -74,9 +77,13 @@ export const MainSection = ({ path: myFeedPath, action: () => onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'), - icon: () => ( - - ), + icon: isV2 + ? (active: boolean) => ( + } /> + ) + : () => ( + + ), } : { title: 'Home', @@ -110,28 +117,31 @@ export const MainSection = ({ claimableMilestoneCount > 0 ? `#${gameCenterMilestoneSectionId}` : '' }`; - const gameCenter = isLoggedIn - ? { - icon: (active: boolean) => ( - } /> - ), - title: 'Game Center', - path: gameCenterPath, - isForcedLink: true, - requiresLogin: true, - ...(claimableMilestoneCount > 0 && { - rightIcon: () => ( - - {claimableMilestoneCount} - + // v2: Game Center has its own rail icon, so suppress the inline + // entry to avoid duplicating navigation in the Home panel list. + const gameCenter = + isLoggedIn && !isV2 + ? { + icon: (active: boolean) => ( + } /> ), - }), - } - : undefined; + title: 'Game Center', + path: gameCenterPath, + isForcedLink: true, + requiresLogin: true, + ...(claimableMilestoneCount > 0 && { + rightIcon: () => ( + + {claimableMilestoneCount} + + ), + }), + } + : undefined; const yearInReview = showYearInReview ? { @@ -196,6 +206,7 @@ export const MainSection = ({ isCustomDefaultFeed, isLoggedIn, isPlus, + isV2, onNavTabClick, showYearInReview, user, diff --git a/packages/shared/src/components/sidebar/sections/ProfileSection.tsx b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx new file mode 100644 index 00000000000..762892737c2 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx @@ -0,0 +1,99 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { + AnalyticsIcon, + CoinIcon, + DevCardIcon, + JobIcon, + MedalBadgeIcon, + UserIcon, +} from '../../icons'; +import { Section } from '../Section'; +import type { SidebarSectionProps } from './common'; +import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; +import { useAlertsContext } from '../../../contexts/AlertContext'; +import { useLogOpportunityNudgeClick } from '../../../hooks/log/useLogOpportunityNudgeClick'; + +export const ProfileSection = ({ + isItemsButton, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement | null => { + const hasAccessToCores = useHasAccessToCores(); + const { user } = useAuthContext(); + const { alerts } = useAlertsContext(); + const logOpportunityNudgeClick = useLogOpportunityNudgeClick(); + + const menuItems: SidebarMenuItem[] = useMemo(() => { + if (!user?.username) { + return []; + } + + return [ + { + title: 'Your profile', + path: `${webappUrl}${user.username}`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Jobs', + path: `${webappUrl}jobs${ + alerts.opportunityId ? `/${alerts.opportunityId}` : '' + }`, + action: logOpportunityNudgeClick, + icon: (active: boolean) => ( + } /> + ), + }, + ...(hasAccessToCores + ? [ + { + title: 'Core wallet', + path: walletUrl, + icon: (active: boolean) => ( + } /> + ), + }, + ] + : []), + { + title: 'Achievements', + path: `${webappUrl}${user.username}/achievements`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'DevCard', + path: `${settingsUrl}/customization/devcard`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Analytics', + path: `${webappUrl}analytics`, + icon: (active: boolean) => ( + } /> + ), + }, + ]; + }, [alerts.opportunityId, hasAccessToCores, logOpportunityNudgeClick, user]); + + if (menuItems.length === 0) { + return null; + } + + return ( +
+ ); +}; diff --git a/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx new file mode 100644 index 00000000000..00f42d5613a --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/SettingsPanelSection.tsx @@ -0,0 +1,310 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { + AddUserIcon, + AppIcon, + BellIcon, + BlockIcon, + CreditCardIcon, + EditIcon, + EmbedIcon, + ExitIcon, + EyeIcon, + FeatherIcon, + HashtagIcon, + HotIcon, + InviteIcon, + JobIcon, + MagicIcon, + MailIcon, + NewTabIcon, + OrganizationIcon, + TerminalIcon, + TourIcon, + TrendingIcon, + UserIcon, +} from '../../icons'; +import { GraduationIcon } from '../../icons/Graduation'; +import { MedalIcon } from '../../icons/Medal'; +import { VolunteeringIcon } from '../../icons/Volunteering'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { Section } from '../Section'; +import type { SidebarSectionProps } from './common'; +import { settingsUrl } from '../../../lib/constants'; +import { LogoutReason } from '../../../lib/user'; +import { logout } from '../../../contexts/AuthContext'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../modals/common/types'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetId } from '../../../lib/log'; + +const settingsDefaultPath = `${settingsUrl}/profile`; + +type SettingsGroup = { + key: string; + title?: string; + items: SidebarMenuItem[]; +}; + +export const SettingsPanelSection = ({ + isItemsButton, + ...defaultRenderSectionProps +}: SidebarSectionProps): ReactElement => { + const { openModal } = useLazyModal(); + const { logEvent } = useLogContext(); + + const groups: SettingsGroup[] = useMemo( + () => [ + { + key: 'main', + items: [ + { + title: 'Profile details', + path: settingsDefaultPath, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Account & Security', + path: `${settingsUrl}/security`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Notifications', + path: `${settingsUrl}/notifications`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Job preferences', + path: `${settingsUrl}/job-preferences`, + icon: (active: boolean) => ( + } /> + ), + action: () => + logEvent({ + event_name: LogEvent.ClickCandidatePreferences, + target_id: TargetId.ProfileSettingsMenu, + }), + }, + { + title: 'Appearance', + path: `${settingsUrl}/appearance`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Posting', + path: `${settingsUrl}/composition`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Invite Friends', + path: `${settingsUrl}/invite`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'feed', + title: 'Feed settings', + items: [ + { + title: 'General', + path: `${settingsUrl}/feed/general`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Tags', + path: `${settingsUrl}/feed/tags`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Content sources', + path: `${settingsUrl}/feed/sources`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Content preferences', + path: `${settingsUrl}/feed/preferences`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'AI superpowers', + path: `${settingsUrl}/feed/ai`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Blocked content', + path: `${settingsUrl}/feed/blocked`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'career', + title: 'Career', + items: [ + { + title: 'Work Experience', + path: `${settingsUrl}/profile/experience/work`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Education', + path: `${settingsUrl}/profile/experience/education`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Certifications', + path: `${settingsUrl}/profile/experience/certification`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Open Source', + path: `${settingsUrl}/profile/experience/opensource`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Projects & Publications', + path: `${settingsUrl}/profile/experience/project`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Volunteering', + path: `${settingsUrl}/profile/experience/volunteering`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'gamification', + title: 'Gamification', + items: [ + { + title: 'Feature visibility', + path: `${settingsUrl}/customization/gamification`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Streaks', + path: `${settingsUrl}/customization/streaks`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'developers', + title: 'Developers', + items: [ + { + title: 'API Access', + path: `${settingsUrl}/api`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Integrations', + path: `${settingsUrl}/customization/integrations`, + icon: (active: boolean) => ( + } /> + ), + }, + ], + }, + { + key: 'billing', + title: 'Billing and Monetization', + items: [ + { + title: 'Subscriptions', + path: `${settingsUrl}/subscription`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Organizations', + path: `${settingsUrl}/organization`, + icon: (active: boolean) => ( + } /> + ), + }, + { + title: 'Ads dashboard', + icon: (active: boolean) => ( + } /> + ), + action: () => openModal({ type: LazyModal.AdsDashboard }), + }, + ], + }, + { + key: 'logout', + items: [ + { + title: 'Log out', + icon: (active: boolean) => ( + } /> + ), + action: () => logout(LogoutReason.ManualLogout), + }, + ], + }, + ], + [logEvent, openModal], + ); + + return ( + <> + {groups.map((group) => ( +
+ ))} + + ); +}; diff --git a/packages/shared/src/components/squads/SquadPageHeader.tsx b/packages/shared/src/components/squads/SquadPageHeader.tsx index f9eb68882a4..3f3dae10779 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -37,6 +37,12 @@ interface SquadPageHeaderProps { squad: Squad; members: BasicSourceMember[]; shouldUseListMode: boolean; + /** + * v2: hide the in-card `` when the unified PageHeader at + * the top of the floating card already hosts the action bar. Keeps the + * squad identity card focused on identity + sharing. + */ + hideHeaderBar?: boolean; } const MAX_WIDTH = 'laptopL:max-w-[38.5rem]'; @@ -46,6 +52,7 @@ export function SquadPageHeader({ squad, members, shouldUseListMode, + hideHeaderBar = false, }: SquadPageHeaderProps): ReactElement { const { openModal } = useLazyModal(); const allowedToPost = verifyPermission(squad, SourcePermissions.Post); @@ -154,7 +161,9 @@ export function SquadPageHeader({ {squad.description} )} - + {!hideHeaderBar && ( + + )} ; @@ -49,6 +51,8 @@ export const SquadDirectoryLayout = ( const { pathname, asPath } = useRouter(); const { categoryPaths, isMobileLayout } = useSquadDirectoryLayout(); const buttonSize = isMobileLayout ? ButtonSize.XSmall : ButtonSize.Small; + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; useEffect(() => { const element = document?.getElementById?.(`squad-item-discover-${id}`); @@ -57,41 +61,77 @@ export const SquadDirectoryLayout = ( const isDiscover = pathname === squadCategoriesPaths.discover; - return ( - - {isDiscover && ( -
- )} + const tabItems = Object.entries(categoryPaths ?? {}).map( + ([category, path]) => ( + + ), + ); -
-
- Squads - } variant={ButtonVariant.Primary} /> -
-
- - {Object.entries(categoryPaths ?? {}).map(([category, path]) => ( - - ))} + return ( + <> + {isV2Laptop && ( + // v2: unified page-header strip at the top of the floating card + // hosts the category tabs + New Squad action. The navbar's own + // mobile bleed/border classes are zeroed out so it sits flush + // inside the slim header row. +
+ + {tabItems} -
+
-
-
-
+ )} + - {children} -
- + {isDiscover && ( +
+ )} + +
+
+ Squads + } + variant={ButtonVariant.Primary} + /> +
+
+ + {tabItems} + +
+ +
+
+
+
+ {children} +
+ + ); }; diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index b0a1cdb2b79..72c85ee70ce 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -24,6 +24,7 @@ export enum InteractivePopupPosition { LeftEnd = 'leftEnd', ProfileMenu = 'profileMenu', Screen = 'screen', + SidebarSupportMenu = 'sidebarSupportMenu', } type CloseButtonProps = { @@ -62,6 +63,7 @@ const positionClass: Record = { leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), screen: 'inset-0 w-screen h-screen', + sidebarSupportMenu: 'left-16 bottom-3 ml-2', }; const leftPositions = [ @@ -142,6 +144,7 @@ function InteractivePopup({ {...props} > {finalPosition !== InteractivePopupPosition.ProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarSupportMenu && onClose && ( diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 406a21aea48..85b0e5b8f36 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -6,6 +6,7 @@ import { RequestKey, } from '@dailydotdev/shared/src/lib/query'; import { useViewSize, ViewSize } from '@dailydotdev/shared/src/hooks'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { useQueryState } from '@dailydotdev/shared/src/hooks/utils/useQueryState'; import { useRouter } from 'next/router'; import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; @@ -59,6 +60,12 @@ export const navigationKey = generateQueryKey( null, ); +// Portal target id for the v2 settings PageHeader strip. AccountPageContainer +// renders its `` into this slot so the strip spans the full +// floating-card width instead of being trapped inside the `max-w-5xl` +// content wrapper below. +export const SETTINGS_PAGE_HEADER_PORTAL_ID = 'settings-page-header-portal'; + export default function SettingsLayout({ children, }: PropsWithChildren): ReactElement { @@ -66,6 +73,11 @@ export default function SettingsLayout({ const { user: profile, isAuthReady } = useContext(AuthContext); const isMobile = useViewSize(ViewSize.MobileL); const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); + // v2 + laptop: the sidebar's `SettingsPanelSection` already provides the + // settings navigation, so hide the inline ProfileSettingsMenuDesktop here + // to avoid showing the same nav twice. + const isV2Laptop = isV2; const canPurchaseCores = useCanPurchaseCores(); const [isOpen, setIsOpen] = useQueryState({ key: navigationKey, @@ -149,6 +161,12 @@ export default function SettingsLayout({ )} + {/* v2 PageHeader strip slot. Rendered outside the `max-w-5xl` content + wrapper below so the strip spans the full floating-card width. + AccountPageContainer portals into this on laptop v2. */} + {isV2Laptop && ( +
+ )}

Settings

{isMobile ? ( @@ -158,7 +176,9 @@ export default function SettingsLayout({ onClose={() => router.push(profile.permalink)} /> ) : ( - + // v2 sidebar panel already shows the settings nav — only render + // the desktop menu for control / tablet. + !isV2Laptop && )} {children}
@@ -168,8 +188,12 @@ export default function SettingsLayout({ export const getSettingsLayout = (page: ReactNode): ReactNode => getFooterNavBarLayout( + // Keep `showSidebar: true` so the v2 dual-sidebar rail appears alongside + // the settings menu (matches designer mock). Control variant also gets + // the legacy sidebar shown on settings pages — small UX change for + // control but the rail-on-settings consistency is worth it. getMainLayout({page}, null, { screenCentered: true, - showSidebar: false, + showSidebar: true, }), ); diff --git a/packages/webapp/pages/[userId]/achievements.tsx b/packages/webapp/pages/[userId]/achievements.tsx index 47d9190e8ea..1f737c515ae 100644 --- a/packages/webapp/pages/[userId]/achievements.tsx +++ b/packages/webapp/pages/[userId]/achievements.tsx @@ -9,6 +9,8 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { ProfileAchievements } from '@dailydotdev/shared/src/features/profile/components/achievements/ProfileAchievements'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import type { ProfileLayoutProps } from '../../components/layouts/ProfileLayout'; import { getLayout as getProfileLayout, @@ -27,6 +29,8 @@ const ProfileAchievementsPage = ({ }: ProfileLayoutProps): ReactElement => { const { user: loggedUser } = useContext(AuthContext); const isSameUser = user && loggedUser?.id === user.id; + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const seo: NextSeoProps = { ...getProfileSeoDefaults( @@ -51,6 +55,7 @@ const ProfileAchievementsPage = ({ Achievements + {isV2Laptop && }
diff --git a/packages/webapp/pages/analytics/index.tsx b/packages/webapp/pages/analytics/index.tsx index 1fbfaee8d16..827273e6b4c 100644 --- a/packages/webapp/pages/analytics/index.tsx +++ b/packages/webapp/pages/analytics/index.tsx @@ -9,6 +9,8 @@ import { pageBorders, } from '@dailydotdev/shared/src/components/utilities'; import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import classNames from 'classnames'; import { Typography, @@ -94,6 +96,8 @@ type ImpressionNode = { const Analytics = (): ReactElement => { const { user } = useAuthContext(); const userTimezone = user?.timezone || DEFAULT_TIMEZONE; + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const analyticsQueryKey = generateQueryKey( RequestKey.UserPostsAnalytics, @@ -232,19 +236,22 @@ const Analytics = (): ReactElement => { return ( + {isV2Laptop && }
- - - Analytics - - + + Analytics + + + )} Overview (last 45 days) diff --git a/packages/webapp/pages/briefing/index.tsx b/packages/webapp/pages/briefing/index.tsx index 9d1849dd89e..4959352876d 100644 --- a/packages/webapp/pages/briefing/index.tsx +++ b/packages/webapp/pages/briefing/index.tsx @@ -26,6 +26,8 @@ import { useViewSizeClient, ViewSize, } from '@dailydotdev/shared/src/hooks'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { Origin, TargetId } from '@dailydotdev/shared/src/lib/log'; import { usePostModalNavigation } from '@dailydotdev/shared/src/hooks/usePostModalNavigation'; import { PostModalMap } from '@dailydotdev/shared/src/components/Feed'; @@ -144,6 +146,9 @@ const Page = (): ReactElement => { } }, [selectedPost]); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; + if (!isActionsFetched) { return null; } @@ -152,39 +157,63 @@ const Page = (): ReactElement => { return ( + {isV2Laptop && ( + + {isNotPlus && !emptyFeed && !hasTodayBrief && ( + + )} + - )} -
-
+ className="laptop:hidden" + tag="a" + icon={} + size={ButtonSize.Small} + variant={ButtonVariant.Tertiary} + /> + + + Presidential briefings + +
+ {isNotPlus && !emptyFeed && !hasTodayBrief && ( + + )} +
+ + )}
{isNotPlus && !!firstBrief && diff --git a/packages/webapp/pages/game-center/index.tsx b/packages/webapp/pages/game-center/index.tsx index cbe05f5c1e0..cbf1692819c 100644 --- a/packages/webapp/pages/game-center/index.tsx +++ b/packages/webapp/pages/game-center/index.tsx @@ -44,6 +44,8 @@ import { StaleTime, } from '@dailydotdev/shared/src/lib/query'; import { LayoutHeader } from '@dailydotdev/shared/src/components/layout/common'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; import { Divider, ResponsivePageContainer, @@ -238,6 +240,8 @@ function GameCenterPage({ const router = useRouter(); const { user } = useAuthContext(); const { optOutLevelSystem } = useSettingsContext(); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const { value: isAchievementTrackingEnabled } = useConditionalFeature({ feature: achievementTrackingWidgetFeature, shouldEvaluate: !!user, @@ -704,19 +708,22 @@ function GameCenterPage({ return ( + {isV2Laptop && }
- - - Game Center - - + + Game Center + + + )}
diff --git a/packages/webapp/pages/jobs/index.tsx b/packages/webapp/pages/jobs/index.tsx index 0fa4cf61cd4..1e5610c89f4 100644 --- a/packages/webapp/pages/jobs/index.tsx +++ b/packages/webapp/pages/jobs/index.tsx @@ -18,9 +18,38 @@ import { IntroHeader } from '@dailydotdev/shared/src/features/opportunity/compon import { OpportunityFAQ } from '@dailydotdev/shared/src/features/opportunity/components/OpportunityFAQ'; import { OpportunityBenefits } from '@dailydotdev/shared/src/features/opportunity/components/OpportunityBenefits'; import { OpportunityHowItWorks } from '@dailydotdev/shared/src/features/opportunity/components/OpportunityHowItWorks'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; +import { useLayoutVariant } from '@dailydotdev/shared/src/hooks/layout/useLayoutVariant'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { FilterIcon } from '@dailydotdev/shared/src/components/icons'; +import { settingsUrl, webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { getLayout } from '../../components/layouts/MainLayout'; +const JobsPageHeader = (): ReactElement => ( + + + + + + +
+ ); + })} + +); + const AccountNotificationsPage = (): ReactElement => { const { isLoadingPreferences } = useNotificationSettings(); + const [activeTab, setActiveTab] = useState('in-app'); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; + + // Control variant keeps the legacy TabContainer rendering — no change. + if (!isV2Laptop) { + if (isLoadingPreferences) { + return
; + } + return ( + + + + + + + + + + + ); + } + + // v2 + laptop: the tabs live inside the master PageHeader strip via + // AccountPageContainer's title slot (NotificationsTabs renders inline as + // the title node). `-my-3` cancels the header's `py-3` so the tab + // underline lands flush on the header's bottom border. + const tabsTitle = ( +
+ +
+ ); + + const body = + activeTab === 'in-app' ? ( + + ) : ( + + ); if (isLoadingPreferences) { - return
; + return ( + +
+ + ); } return ( - - - - - - - - - - + + {body} + ); }; diff --git a/packages/webapp/pages/settings/profile/experience/edit.tsx b/packages/webapp/pages/settings/profile/experience/edit.tsx index d1acb8892dd..baa5bd38ef1 100644 --- a/packages/webapp/pages/settings/profile/experience/edit.tsx +++ b/packages/webapp/pages/settings/profile/experience/edit.tsx @@ -55,10 +55,10 @@ type DefaultValues = UserExperience & { }; type PageProps = { - experience: DefaultValues | null; + experience: DefaultValues; }; -const splitMonthYear = (value?: string) => { +const splitMonthYear = (value?: string | null) => { if (!value) { return ['', '']; } @@ -116,7 +116,7 @@ export const getServerSideProps: GetServerSideProps = async ({ const { cookies } = getCookiesAndHeadersFromRequest(req); const result = await getUserExperienceById(id as string, { Cookie: cookies }); - if (!result || !result.isOwner) { + if (!result) { return { redirect: { destination: `/settings/profile/experience/${typeParam}`, @@ -125,27 +125,39 @@ export const getServerSideProps: GetServerSideProps = async ({ }; } - delete result.isOwner; - const [startedAtMonth, startedAtYear] = splitMonthYear(result.startedAt); - const [endedAtMonth, endedAtYear] = splitMonthYear(result.endedAt); + const { isOwner, ...experienceResult } = result; + if (!isOwner) { + return { + redirect: { + destination: `/settings/profile/experience/${typeParam}`, + permanent: false, + }, + }; + } + + const [startedAtMonth, startedAtYear] = splitMonthYear( + experienceResult.startedAt, + ); + const [endedAtMonth, endedAtYear] = splitMonthYear(experienceResult.endedAt); return { props: { experience: { - ...result, - companyId: result.company?.id || '', - customCompanyName: result.company?.name || result.customCompanyName, - storedCustomCompanyName: result.customCompanyName, - company: result.company, + ...experienceResult, + companyId: experienceResult.company?.id || '', + customCompanyName: + experienceResult.company?.name || experienceResult.customCompanyName, + storedCustomCompanyName: experienceResult.customCompanyName, + company: experienceResult.company, startedAtMonth, startedAtYear, endedAtMonth, endedAtYear, - current: !result.endedAt, - skills: result.skills?.map((skill) => skill.value), - location: result.location, - externalLocationId: result.location?.externalId || '', - repositorySearch: result.repository?.name || '', + current: !experienceResult.endedAt, + skills: experienceResult.skills?.map((skill) => skill.value), + location: experienceResult.location, + externalLocationId: experienceResult.location?.externalId || '', + repositorySearch: experienceResult.repository?.name || '', }, }, }; @@ -198,18 +210,19 @@ const Page = ({ experience }: PageProps): ReactElement => { }`} actions={ } > - {renderExperienceForm(experience?.type, experience)} + {renderExperienceForm(experience.type, experience)} {experience?.id && ( { const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const { shouldShowAuthBanner } = useOnboardingActions(); const shouldShowTagSourceSocialProof = shouldShowAuthBanner && isLaptop; const { user } = useContext(AuthContext); @@ -266,6 +270,7 @@ const SourcePage = ({ dangerouslySetInnerHTML={{ __html: jsonLd }} /> + {isV2Laptop && } ; @@ -92,53 +97,74 @@ const SourcesPage = ({ const uniqueSources = Array.from( new Map(allSources.map((source) => [source.id, source])).values(), ).slice(0, 100); + let suggestSourceVariant = ButtonVariant.Float; + if (isLaptop) { + suggestSourceVariant = ButtonVariant.Secondary; + } + if (isV2Laptop) { + suggestSourceVariant = ButtonVariant.Tertiary; + } + + const suggestSourceButton = ( + + ); return ( - - -