diff --git a/packages/extension/src/newtab/ExtensionSignInStrip.tsx b/packages/extension/src/newtab/ExtensionSignInStrip.tsx new file mode 100644 index 00000000000..da2a82265b0 --- /dev/null +++ b/packages/extension/src/newtab/ExtensionSignInStrip.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { + Typography, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; + +// Sticky strip pinned to the top of the new tab for logged-out users so +// the auth CTAs stay reachable while scrolling. Shares the feed's +// primary background so it reads as part of the same surface. +export const ExtensionSignInStrip = (): ReactElement | null => { + const { isAuthReady, isLoggedIn, showLogin } = useAuthContext(); + + if (!isAuthReady || isLoggedIn) { + return null; + } + + const onLogIn = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: true }, + }); + const onSignUp = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: false }, + }); + + return ( +
+
+ + Sign in to personalize your feed and save what matters. + +
+ + +
+
+
+ ); +}; diff --git a/packages/extension/src/newtab/ExtensionTopBanners.tsx b/packages/extension/src/newtab/ExtensionTopBanners.tsx new file mode 100644 index 00000000000..af097d71d09 --- /dev/null +++ b/packages/extension/src/newtab/ExtensionTopBanners.tsx @@ -0,0 +1,217 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { TopHero } from '@dailydotdev/shared/src/components/marketing/banners/HeroBottomBanner'; +import { useReadingReminderHero } from '@dailydotdev/shared/src/hooks/notifications/useReadingReminderHero'; +import { + fileValidation, + useUploadCv, +} from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useActions } from '@dailydotdev/shared/src/hooks'; +import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useIsShortcutsHubEnabled } from '@dailydotdev/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled'; +import { useShortcutLinks } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutLinks'; +import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils/useThemedAsset'; +import ReadingReminderCatLaptop from '@dailydotdev/shared/src/components/marketing/banners/ReadingReminderCatLaptop'; +import { + cloudinaryShortcutsIconsGmail, + cloudinaryShortcutsIconsOpenai, + cloudinaryShortcutsIconsReddit, + uploadCvBgMobile, +} from '@dailydotdev/shared/src/lib/image'; + +// Bare-illustration frame matched across the three top cards so they +// line up vertically. Slightly wider than tall to give the CV cluster +// horizontal room without cropping. +const illustrationFrameClass = + '!m-0 flex h-24 w-32 shrink-0 items-center justify-center self-center tablet:h-28 tablet:w-36'; + +const CvIllustration = (): ReactElement => ( +
+ +
+); + +// Compact cat illustration scaled to match the CV / Shortcuts frames so +// the three cards in the row share the same height. +const CompactReminderCat = (): ReactElement => ( + +); + +const ShortcutsIllustration = (): ReactElement => { + const { githubShortcut } = useThemedAsset(); + const icons = [ + { src: cloudinaryShortcutsIconsGmail, rotate: '-rotate-12' }, + { src: githubShortcut, rotate: 'rotate-0' }, + { src: cloudinaryShortcutsIconsReddit, rotate: 'rotate-12' }, + { src: cloudinaryShortcutsIconsOpenai, rotate: '-rotate-6' }, + ]; + + return ( +
+
+ {icons.map(({ src, rotate }) => ( +
+ +
+ ))} +
+
+ ); +}; + +type UseShortcutsOnboardingResult = { + shouldShow: boolean; + onAddClick: () => void; +}; + +const useShortcutsOnboarding = (): UseShortcutsOnboardingResult => { + const hubEnabled = useIsShortcutsHubEnabled(); + const { showTopSites, toggleShowTopSites } = useSettingsContext(); + const { openModal } = useLazyModal(); + const { completeAction, checkHasCompleted } = useActions(); + const { shortcutLinks } = useShortcutLinks(); + + const hasShortcuts = (shortcutLinks?.length ?? 0) > 0; + const shouldShow = !hasShortcuts; + + const completeFirstSession = () => { + if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { + completeAction(ActionType.FirstShortcutsSession); + } + }; + + const onAddClick = () => { + completeFirstSession(); + if (!showTopSites) { + toggleShowTopSites(); + } + openModal({ + type: hubEnabled ? LazyModal.ShortcutsManage : LazyModal.CustomLinks, + }); + }; + + return { shouldShow, onAddClick }; +}; + +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 }); + const { isLoggedIn, isAuthReady } = useAuthContext(); + const { onUpload, shouldShow: shouldShowCv } = useUploadCv(); + const { completeAction } = useActions(); + const fileInputRef = useRef(null); + const shortcuts = useShortcutsOnboarding(); + + // Logged-out users get the dedicated sticky sign-in strip rendered + // higher up in `MainFeedPage`. This component is logged-in cards only. + if (!isAuthReady || !isLoggedIn) { + return null; + } + + const cards: ReactElement[] = []; + + if (reminder.shouldShow) { + cards.push( + } + onCtaClick={() => { + reminder.onEnable(); + }} + onClose={() => { + reminder.onDismiss(); + }} + />, + ); + } + + if (shouldShowCv) { + cards.push( + } + onCtaClick={() => fileInputRef.current?.click()} + onClose={() => completeAction(ActionType.ClosedProfileBanner)} + />, + ); + } + + if (shortcuts.shouldShow) { + cards.push( + } + onCtaClick={shortcuts.onAddClick} + />, + ); + } + + if (cards.length === 0) { + return null; + } + + return ( + <> + `.${ext}`) + .join(',')} + className="hidden" + onChange={(event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + onUpload(file); + // eslint-disable-next-line no-param-reassign + event.target.value = ''; + }} + /> +
+ {cards} +
+ + ); +}; diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index 20f32e148d7..b7ab67d2813 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -31,6 +31,8 @@ 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 { ExtensionSignInStrip } from './ExtensionSignInStrip'; import { CompanionPopupButton } from '../companion/CompanionPopupButton'; import { useCompanionSettings } from '../companion/useCompanionSettings'; import { getDefaultLink } from './dnd'; @@ -198,6 +200,12 @@ 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,81 +182,142 @@ 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} - - - + {/* v2 hoists the page-header strip OUT of FeedPageLayoutComponent + so it spans the full floating-card width without being clamped + by `FeedPageLayoutList`'s 680px laptop max-width. */} + {isV2Laptop && ( + + {sortDropdown} + {shareButton} + {folderMenu} + + )} + + {children} + {!isV2Laptop && ( + <> + + + {title} + + + + {searchChildren} + {sortDropdown} + {shareButton} + {folderMenu} + + )} - > - {searchChildren} - {!isSearchResults && ( - } - iconOnly - selectedIndex={selectedSort} - options={bookmarkSortOptionLabels} - onChange={(_, index) => setSelectedSort(index)} - buttonVariant={ButtonVariant.Float} - buttonSize={ButtonSize.Medium} - drawerProps={{ displayCloseButton: true }} + + {showSharedBookmarks && ( + setShowSharedBookmarks(false)} /> )} - {!isFolderPage && ( - } - onClick={() => setShowSharedBookmarks(true)} - > - {isLaptop ? Share bookmarks : null} - - )} - {folder && !isReminderOnly && ( - - )} - - - {showSharedBookmarks && ( - setShowSharedBookmarks(false)} - /> - )} -
- - {plusEntryBookmark && ( - + + {plusEntryBookmark && ( + + )} +
+ {/* Digest upsell only shown when bookmarks are empty to engage new/inactive users */} + {!plusEntryBookmark && isEmptyFeed && } + {tokenRefreshed && (isSearchResults || loadedSort) && ( + )} - - {/* Digest upsell only shown when bookmarks are empty to engage new/inactive users */} - {!plusEntryBookmark && isEmptyFeed && } - {tokenRefreshed && (isSearchResults || loadedSort) && ( - - )} -
+ + ); } diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index dbbbf923e83..e53e76d95e3 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -79,6 +79,7 @@ import { BriefBannerFeed } from './cards/brief/BriefBanner/BriefBannerFeed'; import { ActionType } from '../graphql/actions'; import { TopHero } from './marketing/banners/HeroBottomBanner'; import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; +import { useLayoutVariant } from '../hooks/layout/useLayoutVariant'; import { useLegacyPostLayoutOptOut } from './post/reader/hooks/useLegacyPostLayoutOptOut'; import { useReaderModalEligibility } from './post/reader/hooks/useReaderModalEligibility'; @@ -361,6 +362,7 @@ export default function Feed({ readerEligiblePostTypes.has(post.type), [isReaderModalFeatureReady, isReaderModalOn, readerEligiblePostTypes], ); + const { isV2 } = useLayoutVariant(); const { adjustedHeroInsertIndex, shouldShowTopHero, @@ -373,6 +375,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, }); const isMobileViewport = !isTabletViewport; diff --git a/packages/shared/src/components/MainFeedLayout.tsx b/packages/shared/src/components/MainFeedLayout.tsx index 767a8a16c83..ca6bea71a1a 100644 --- a/packages/shared/src/components/MainFeedLayout.tsx +++ b/packages/shared/src/components/MainFeedLayout.tsx @@ -52,10 +52,13 @@ import { useFeedName } from '../hooks/feed/useFeedName'; import { useConditionalFeature, useFeedLayout, + useFeeds, useScrollRestoration, useViewSize, ViewSize, } from '../hooks'; +import { feedNameToHeading } from './feeds/FeedContainer'; +import { pageHeaderClassName } from './layout/PageHeader'; import { customFeedVersion, discussedFeedVersion, @@ -83,6 +86,7 @@ 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'; const FeedExploreHeader = dynamic( () => @@ -236,6 +240,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 { @@ -343,9 +348,10 @@ export default function MainFeedLayout({ ) : null, - [showExploreChips, exploreCategories, isFeedTagsPending], + [showExploreChips, exploreCategories, isFeedTagsPending, isV2], ); const { isSearchPageLaptop } = useSearchResultsLayout(); @@ -696,61 +702,120 @@ 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; + + // v2 also hoists the regular page-header strip up here, OUTSIDE + // `FeedPageLayoutComponent`, so it can span the full floating-card + // width without being clamped by `FeedPageLayoutList`'s 680px max + // (which keeps list cards at a comfortable reading width). + const { feeds: customFeedsData } = useFeeds(); + const feedHeading = useMemo(() => { + if (feedName === SharedFeedPage.Custom) { + const customFeed = customFeedsData?.edges.find( + ({ node }) => + node.id === router.query.slugOrId || + node.slug === router.query.slugOrId, + )?.node; + if (customFeed?.flags?.name) { + return customFeed.flags.name; + } + } + if (feedName && feedName in feedNameToHeading) { + return feedNameToHeading[feedName as keyof typeof feedNameToHeading]; + } + return ''; + }, [customFeedsData, feedName, router.query.slugOrId]); + const v2ActionButtons = feedProps?.actionButtons; + const showFeedV2PageHeader = + isV2 && + !isExtension && + !showExploreV2PageHeader && + !isSearchPageLaptop && + (!!v2ActionButtons || !!feedHeading); + return ( - - {isAnyExplore && } - {isSearchOn && !isSearchPageLaptop && search} - {isSearchOn && isFinder && !isSearchPageLaptop && ( - - )} - {shouldShowReadingReminderOnHomepage && ( - - )} - {isHomePage && ( - + <> + {showExploreV2PageHeader && ( +
+ +
)} - {!isExtension && isHomePage && ( - + {showFeedV2PageHeader && ( +
+ {v2ActionButtons || ( + + {feedHeading} + + )} +
)} - {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 2b3000239b7..56797a4bef7 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(); @@ -101,8 +119,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; @@ -181,7 +224,13 @@ function MainLayoutComponent({ isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -199,35 +248,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/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index dbcac7df244..fd379666b23 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -31,6 +31,7 @@ import { import { useUploadCv } from '../../features/profile/hooks/useUploadCv'; import { TargetId } from '../../lib/log'; import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; +import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; export interface FeedContainerProps { children: ReactNode; @@ -98,7 +99,7 @@ const cardClass = ({ return isList ? 'grid-cols-1' : cardListClass[numberOfCards] ?? 'grid-cols-1'; }; -const feedNameToHeading: Record< +export const feedNameToHeading: Record< Extract< FeedPagesWithMobileLayoutType, | SharedFeedPage.Search @@ -106,7 +107,7 @@ const feedNameToHeading: Record< | SharedFeedPage.Popular | SharedFeedPage.Upvoted | OtherFeedPage.Discussed - | OtherFeedPage.Bookmarks + | OtherFeedPage.Following >, string > = { @@ -115,7 +116,7 @@ const feedNameToHeading: Record< popular: 'Popular', upvoted: 'Most upvoted', discussed: 'Best discussions', - bookmarks: 'Bookmarks', + following: 'Following', }; export const FeedContainer = ({ @@ -138,6 +139,8 @@ export const FeedContainer = ({ const { loadedSettings } = useContext(SettingsContext); const { shouldUseListFeedLayout, isListMode } = useFeedLayout(); const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; const { feedName } = useActiveFeedNameContext(); const activeFeedName = feedName ?? SharedFeedPage.MyFeed; const { isAnyExplore, isExplorePopular, isExploreLatest } = useFeedName({ @@ -275,11 +278,16 @@ export const FeedContainer = ({ > {inlineHeader && header} {topContent} - {isSearch && !shouldUseListFeedLayout && ( - {!!actionButtons && ( @@ -288,7 +296,7 @@ export const FeedContainer = ({ )} {shortcuts} - + )} - ( - - {feedHeading} - {component} - - )} - > - {actionButtons || null} - + {!isV2Laptop && ( + ( + + {feedHeading} + {component} + + )} + > + {actionButtons || null} + + )} {isExtension && shortcuts} {child} @@ -321,8 +341,31 @@ export const FeedContainer = ({
+
+ +
+
+ ); +} + export function AchievementTrackerButton(): ReactElement | null { const { openModal, closeModal } = useLazyModal(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); + const { isV2 } = useLayoutVariant(); const { value: isAchievementTrackingWidgetEnabled, isLoading: isAchievementTrackingWidgetLoading, @@ -141,7 +171,11 @@ export function AchievementTrackerButton(): ReactElement | null { return ( ); } @@ -154,7 +188,11 @@ export function AchievementTrackerButton(): ReactElement | null { return ( ); } @@ -162,8 +200,21 @@ export function AchievementTrackerButton(): ReactElement | null { const buttonContent = (
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..c3d434700a5 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -38,6 +38,7 @@ import { LogEvent, Origin } from '../../lib/log'; import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import { IntroQuestButton } from '../filters/IntroQuestButton'; import { LuckyButton } from '../filters/LuckyButton'; +import { BriefShortcutButton } from '../cards/brief/BriefShortcutButton'; import { ActionType } from '../../graphql/actions'; import { BrowserName, @@ -50,6 +51,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 +96,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(); @@ -124,17 +128,26 @@ export const SearchControlHeader = ({ return null; } + // v2 compact ghost styles for the page-header action strip: 32px + // square icon buttons + 32px-tall text buttons, no border/bg, with + // a surface-hover affordance on hover. + const compactIconButtonClassName = + '!size-8 !rounded-10 !border-transparent !bg-transparent !p-0 hover:!bg-surface-hover'; + const compactTextButtonClassName = + '!h-8 !rounded-10 !border-transparent !bg-transparent !px-3 hover:!bg-surface-hover'; + const dropdownProps: Partial = { className: { label: 'hidden', chevron: 'hidden', - button: '!px-1', + button: isV2Strip ? compactIconButtonClassName : '!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 +182,44 @@ export const SearchControlHeader = ({ ); + const dropdownIconSize = isV2Strip ? IconSize.XSmall : IconSize.Medium; + // When chips are present in the v2 strip the actions cluster moves to + // the right and rendered icon-only so the chips have horizontal room + // to scroll on the left. + const hasV2Chips = isV2Strip && !!chips; const primaryActions = [ - hasFeedActions && , + hasFeedActions && ( + + ), + hasFeedActions && isV2Strip && ( + + ), isUpvoted ? ( } + icon={} selectedIndex={selectedPeriod} options={periodTexts} onChange={(_, index) => setSelectedPeriod(index)} @@ -185,7 +229,7 @@ export const SearchControlHeader = ({ } + icon={} selectedIndex={selectedAlgo} options={algorithmsList} onChange={(_, index) => setSelectedAlgo(index)} @@ -197,15 +241,80 @@ export const SearchControlHeader = ({ origin={ feedName === SharedFeedPage.Custom ? Origin.CustomFeed : Origin.Feed } + buttonProps={ + isV2Strip + ? { + size: ButtonSize.Small, + variant: ButtonVariant.Tertiary, + className: hasV2Chips + ? compactIconButtonClassName + : compactTextButtonClassName, + } + : undefined + } + iconButtonProps={ + isV2Strip + ? { + size: ButtonSize.Small, + variant: ButtonVariant.Tertiary, + className: compactIconButtonClassName, + } + : undefined + } + iconSize={isV2Strip ? IconSize.XSmall : undefined} key="toggle-clickbait-shield" /> ), - hasFeedActions && , - hasFeedActions && , - hasFeedActions && , + hasFeedActions && !isV2Strip && , + hasFeedActions && !isV2Strip && ( + + ), + hasFeedActions && !isV2Strip && , + ]; + // v2: AchievementTrackerButton is right-aligned so the current + // achievement / track CTA stays balanced against the primary + // controls. Non-v2 keeps it inline above. + const rightActions = [ + hasFeedActions && isV2Strip && ( + + ), ]; const secondaryActions = [isLaptop && installExtensionButton]; const actions = primaryActions.filter(Boolean); + const trailingActions = [ + ...rightActions.filter(Boolean), + ...secondaryActions.filter(Boolean), + ]; + + // In v2 the FeedContainer wraps these actions inside its own + // page-header strip. Layout: + // - With chips: chips fill the left (flex-1, scrollable), all + // actions sit on the right as icon-only buttons. + // - Without chips: primary cluster on the left, trailing cluster + // (achievement tracker + install-extension) docked right. + if (isV2Strip) { + if (hasV2Chips) { + return ( +
+
{chips}
+
+ {actions} + {trailingActions} +
+
+ ); + } + return ( +
+
{actions}
+ {trailingActions.length > 0 && ( +
+ {trailingActions} +
+ )} +
+ ); + } const sideActions = secondaryActions.filter(Boolean); return ( @@ -229,13 +338,13 @@ export const SearchControlHeader = ({ ); }} > -
+
{!!chips &&
{chips}
}
{actions}
{sideActions.length > 0 && (
{sideActions}
)} -
+ ); }; diff --git a/packages/shared/src/components/marketing/banners/HeroBottomBanner.tsx b/packages/shared/src/components/marketing/banners/HeroBottomBanner.tsx index 9f6c1a63c1d..351deb137a5 100644 --- a/packages/shared/src/components/marketing/banners/HeroBottomBanner.tsx +++ b/packages/shared/src/components/marketing/banners/HeroBottomBanner.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React from 'react'; import { Button, ButtonVariant } from '../../buttons/Button'; import { MiniCloseIcon } from '../../icons'; @@ -10,14 +10,18 @@ type TopHeroProps = { className?: string; title?: string; subtitle?: string; + ctaLabel?: string; + illustration?: ReactNode; onCtaClick: () => void; - onClose: () => void; + onClose?: () => void; }; export const TopHero = ({ className, title = 'Never miss a learning day', subtitle = 'Turn on your daily reading reminder and keep your routine.', + ctaLabel = 'Enable reminder', + illustration, onCtaClick, onClose, }: TopHeroProps): ReactElement => { @@ -31,14 +35,16 @@ export const TopHero = ({
-
-
- +
+ {illustration ?? ( + + )}
diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 1122dc10e27..263d57185bf 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,10 +13,32 @@ 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, + noTooltip, + active, +}: { + compact?: boolean; + rail?: boolean; + noTooltip?: boolean; + // Optional override — the v2 sidebar wants the bell highlighted on + // any page that owns the Notifications category (incl. its settings + // sub-page), which extends past the bell's own internal check. + active?: boolean; +}): ReactElement { const router = useRouter(); - const atNotificationsPage = router.pathname === notificationsUrl; + // `router.pathname` exact-match drops on legitimate variations (trailing + // slashes, locale prefixes, etc.), leaving the bell looking inactive on + // the very page it points at. Match on the resolved path prefix instead + // so the active styling fires reliably. + const currentPath = (router.asPath ?? router.pathname ?? '').split('?')[0]; + const atNotificationsPage = + active ?? + (currentPath === notificationsUrl || + currentPath.startsWith(`${notificationsUrl}/`)); const { logEvent } = useLogContext(); const { unreadCount } = useNotificationContext(); const isLaptop = useViewSize(ViewSize.Laptop); @@ -31,6 +53,48 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option; + if (rail) { + const railLink = ( + + ); + + if (noTooltip) { + return railLink; + } + + return ( + + {railLink} + + ); + } + return (
diff --git a/packages/shared/src/components/notifications/NotificationsRailPanel.tsx b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx new file mode 100644 index 00000000000..fc4f7ffd7b8 --- /dev/null +++ b/packages/shared/src/components/notifications/NotificationsRailPanel.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { useNotificationContext } from '../../contexts/NotificationsContext'; +import { Typography, TypographyType } from '../typography/Typography'; +import { settingsUrl, webappUrl } from '../../lib/constants'; +import { BellIcon, SettingsIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { RailHoverRow } from '../sidebar/RailHoverRow'; + +// Compact menu in the rail hover card. Lists the destinations the +// notifications rail icon can reach so the hover preview matches the +// rail's click model (same affordance as other category hover cards). +export const NotificationsRailPanel = (): ReactElement => { + const { unreadCount } = useNotificationContext(); + const hasUnread = !!unreadCount; + + return ( +
+ } + label="All activity" + trailing={ + hasUnread ? ( + + {unreadCount} + + ) : undefined + } + /> + } + label="Settings" + /> +
+ ); +}; 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/quest/QuestRailIcon.tsx b/packages/shared/src/components/quest/QuestRailIcon.tsx new file mode 100644 index 00000000000..0a1f06ca7c4 --- /dev/null +++ b/packages/shared/src/components/quest/QuestRailIcon.tsx @@ -0,0 +1,82 @@ +import classNames from 'classnames'; +import type { ReactElement } from 'react'; +import React from 'react'; +import { useQuestDashboard } from '../../hooks/useQuestDashboard'; +import { JoystickIcon } from '../icons'; +import { IconSize } from '../Icon'; + +interface QuestRailIconProps { + active: boolean; +} + +const SIZE = 20; +const STROKE = 2; +const RADIUS = (SIZE - STROKE) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +// Rail icon for the Game Center sidebar tab. Falls back to the joystick +// glyph when the quest dashboard hasn't loaded yet so the rail never +// shows an empty slot during hydration. +export const QuestRailIcon = ({ active }: QuestRailIconProps): ReactElement => { + const { data } = useQuestDashboard(); + const level = data?.level; + + if (!level) { + return ( + + ); + } + + const totalForLevel = level.xpInLevel + level.xpToNextLevel; + const progress = totalForLevel + ? Math.min(100, (level.xpInLevel / totalForLevel) * 100) + : 0; + + return ( + + + + + + + {level.level} + + + ); +}; 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/RailHoverRow.tsx b/packages/shared/src/components/sidebar/RailHoverRow.tsx new file mode 100644 index 00000000000..d13b2f44cac --- /dev/null +++ b/packages/shared/src/components/sidebar/RailHoverRow.tsx @@ -0,0 +1,47 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { useRouter } from 'next/router'; +import Link from '../utilities/Link'; + +interface RailHoverRowProps { + href: string; + icon: ReactNode; + label: string; + trailing?: ReactNode; +} + +// Shared row used by v2 rail hover cards (Notifications, Game Center, +// …) so every panel uses the same row layout, hover affordance, and +// active-state highlight when the user is already on that destination. +export const RailHoverRow = ({ + href, + icon, + label, + trailing, +}: RailHoverRowProps): ReactElement => { + const router = useRouter(); + const currentPath = (router.asPath ?? router.pathname ?? '').split('?')[0]; + const targetPath = href.replace(/^https?:\/\/[^/]+/, ''); + const isActive = currentPath === targetPath; + + return ( + + + + {icon} + + {label} + {trailing} + + + ); +}; 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', + // Discover's first sub-page is the Explore feed (`/posts`); clicking + // the rail icon should land you on that page, matching the submenu. + defaultPath: `${webappUrl}posts`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.Saved, + label: 'Saved', + defaultPath: `${webappUrl}bookmarks`, + icon: (active) => ( + + ), + }, + { + id: SidebarCategory.GameCenter, + label: 'Game Center', + // First sub-page in the Game Center category is the Daily quests + // page (the panel that used to live in the sidebar). Clicking the + // rail icon lands you there; Game Center proper is one click away + // via the hover panel. + defaultPath: `${webappUrl}daily-quests`, + icon: (active) => , + }, + { + id: SidebarCategory.Profile, + label: 'Profile', + icon: (active) => ( + + ), + }, +]; + +const discoverPathFragments = [ + '/posts', + '/tags', + '/sources', + '/users', + '/discussed', +]; +const profilePathFragments = [ + '/analytics', + '/jobs', + '/settings/customization/devcard', + '/wallet', +]; + +const getSidebarCategoryForPath = (activePage: string): SidebarCategoryId => { + // Settings sub-pages that "belong" to another category should keep + // that category's rail icon highlighted (matches the click model: + // user opened the panel for category X → clicked X's settings → + // X's rail icon stays active so the user can still see where they + // are in the app). The general /settings fallback comes last. + if ( + activePage.includes('/notifications') || + activePage.includes('/settings/notifications') + ) { + return SidebarCategory.Notifications; + } + if ( + activePage.includes('/game-center') || + activePage.includes('/daily-quests') || + activePage.includes('/settings/customization/gamification') + ) { + return SidebarCategory.GameCenter; + } + if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) { + return SidebarCategory.Saved; + } + if (activePage.includes('/squads')) { + return SidebarCategory.Squads; + } + if (profilePathFragments.some((path) => activePage.includes(path))) { + return SidebarCategory.Profile; + } + if (activePage.includes('/settings')) { + return SidebarCategory.Settings; + } + if (discoverPathFragments.some((path) => activePage.includes(path))) { + return SidebarCategory.Discover; + } + 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 = 300; +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) => { + // Controlled open + suppression flag: after a click on the trigger we + // close the panel and block reopens until the pointer actually leaves + // the trigger. Otherwise Radix's openDelay timer re-fires while the + // cursor still rests on the just-clicked item and the panel pops back + // up after navigation. + const [open, setOpen] = useState(false); + const suppressOpenRef = useRef(false); + + const handleOpenChange = useCallback((next: boolean) => { + if (next && suppressOpenRef.current) { + return; + } + setOpen(next); + }, []); + + const handleTriggerClick = useCallback(() => { + suppressOpenRef.current = true; + setOpen(false); + }, []); + + const handleTriggerPointerLeave = useCallback(() => { + suppressOpenRef.current = false; + }, []); + + 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 isDark = themeMode === ThemeMode.Dark; + const nextMode = isDark ? ThemeMode.Light : ThemeMode.Dark; + const ActiveIcon = themeIconMap[themeMode]; + + const onToggleTheme = useCallback(() => { + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.Theme, + target_id: nextMode, + }); + setTheme(nextMode); + }, [logEvent, nextMode, setTheme]); + + 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 } = 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]); + + // Escape resets the pinned panel back to Main so the keyboard story + // mirrors the click model — Tab+Enter opens a panel, Escape backs out. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setPendingCategory(SidebarCategory.Main); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + 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); + + // Click navigates to the category's first sub-page (its + // `defaultPath`) — it no longer auto-expands the sidebar. The + // sidebar's open/closed state is controlled solely by the user + // via the dedicated toggle button. + 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.Notifications) { + return ; + } + if (category === SidebarCategory.GameCenter) { + // Daily quests (rail-icon landing) → Game Center hub → Quests + // settings. Same row pattern as the notifications panel; the + // Game Center hub stays reachable from the rail. + return ( +
+ } + label="Daily quests" + /> + } + label="Game Center" + /> + } + label="Quests settings" + /> +
+ ); + } + 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 isNotificationsSelected = + selectedCategory === SidebarCategory.Notifications; + const isHomePanel = selectedCategory === SidebarCategory.Main; + const isSquadsPanel = selectedCategory === SidebarCategory.Squads; + const isUtilityPanelSelected = !isHomePanel; + const utilityPanelTitle = (() => { + if (isSettingsSelected) { + return 'Settings'; + } + if (isNotificationsSelected) { + return 'Notifications'; + } + return selectedLabel ?? ''; + })(); + + return ( + + {sidebarExpanded && ( + + )} + + + {/* + 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..2432263da4f --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx @@ -0,0 +1,291 @@ +import type { MouseEvent, ReactElement, ReactNode } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks'; +import { useLogContext } from '../../contexts/LogContext'; +import { walletUrl } 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 { RootPortal } from '../tooltips/Portal'; +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 StreakPopoverProps = { + streak: UserStreak; + triggerRef: React.RefObject; + onClose: () => void; +}; + +// Manually positioned portal popover: read the trigger's bounding rect +// and render the panel directly below it via a body-level portal. This +// keeps the popover stable (no Tippy auto-flip surprises inside the +// sidebar's transform / overflow context) and ensures it always drops +// down from the streak button as expected. +const StreakPopover = ({ + streak, + triggerRef, + onClose, +}: StreakPopoverProps): ReactElement | null => { + const [position, setPosition] = useState<{ + top: number; + left: number; + } | null>(null); + const popoverRef = useRef(null); + + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const rect = trigger.getBoundingClientRect(); + setPosition({ top: rect.bottom + 8, left: rect.left }); + }, [triggerRef]); + + useLayoutEffect(() => { + updatePosition(); + }, [updatePosition]); + + useEffect(() => { + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [updatePosition]); + + useEffect(() => { + const handleClickOutside = (event: globalThis.MouseEvent) => { + const target = event.target as Node | null; + if ( + target && + !popoverRef.current?.contains(target) && + !triggerRef.current?.contains(target) + ) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose, triggerRef]); + + if (!position) { + return null; + } + + return ( + +
+ +
+
+ ); +}; + +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 streakSlotRef = useRef(null); + + 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 && ( + <> +
+ {isStreaksOpen ? ( + streakSlot + ) : ( + + {streakSlot} + + )} +
+ {streak && isStreaksOpen && ( + setIsStreaksOpen(false)} + /> + )} + + + )} + + + + + } + 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/BookmarkSection.tsx b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx index b747aea9c0b..c7e86ce02d8 100644 --- a/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/BookmarkSection.tsx @@ -46,22 +46,22 @@ export const BookmarkSection = ({ }, [openModal, closeModal, createFolder]); const allMenuItems = [ - briefUIFeatureValue && { + { icon: (active: boolean) => ( - } /> + } /> ), - title: 'Presidential briefings', - path: briefingUrl, + title: 'Quick saves', + path: `${webappUrl}bookmarks`, isForcedLink: true, requiresLogin: true, rightIcon, }, - { + briefUIFeatureValue && { icon: (active: boolean) => ( - } /> + } /> ), - title: 'Quick saves', - path: `${webappUrl}bookmarks`, + title: 'Presidential briefings', + path: briefingUrl, isForcedLink: true, requiresLogin: true, rightIcon, diff --git a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx index ca4ffe739d9..b48067525de 100644 --- a/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx +++ b/packages/shared/src/components/sidebar/sections/DiscoverSection.tsx @@ -8,6 +8,7 @@ import { HashtagIcon, HotIcon, SquadIcon, + TourIcon, } from '../../icons'; import { Section } from '../Section'; import type { SidebarSectionProps } from './common'; @@ -18,26 +19,32 @@ import { ActionType } from '../../../graphql/actions'; import { webappUrl } from '../../../lib/constants'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent } from '../../../lib/log'; +import { OtherFeedPage } from '../../../lib/query'; +import { useLayoutVariant } from '../../../hooks/layout/useLayoutVariant'; + +interface DiscoverSectionProps extends SidebarSectionProps { + onNavTabClick?: (tab: string) => void; +} export const DiscoverSection = ({ isItemsButton, + onNavTabClick, ...defaultRenderSectionProps -}: SidebarSectionProps): ReactElement => { +}: DiscoverSectionProps): ReactElement => { const { completeAction } = useActions(); const { user } = useAuthContext(); const { logEvent } = useLogContext(); + const { isV2 } = useLayoutVariant(); + const HotTakesIcon = isV2 ? TourIcon : HotIcon; const menuItems: SidebarMenuItem[] = useMemo(() => { return [ { icon: (active: boolean) => ( } /> ), - title: 'Hot Takes', - requiresLogin: true, - path: `${webappUrl}?openModal=hottakes`, - action: () => { - logEvent({ event_name: LogEvent.OpenHotAndCold }); - }, + title: 'Explore', + path: '/posts', + action: () => onNavTabClick?.(OtherFeedPage.Explore), }, { icon: (active: boolean) => ( @@ -72,8 +79,19 @@ export const DiscoverSection = ({ } }, }, + { + icon: (active: boolean) => ( + } /> + ), + title: 'Hot Takes', + requiresLogin: true, + path: `${webappUrl}?openModal=hottakes`, + action: () => { + logEvent({ event_name: LogEvent.OpenHotAndCold }); + }, + }, ].filter(Boolean); - }, [completeAction, user, logEvent]); + }, [completeAction, user, logEvent, onNavTabClick, HotTakesIcon]); return (
{ const { user, isLoggedIn } = useAuthContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); + const { isV2 } = useLayoutVariant(); const isPlus = user?.isPlus; const { value: isApiLanding } = useConditionalFeature({ feature: featurePlusApiLanding, @@ -74,9 +76,13 @@ export const MainSection = ({ path: myFeedPath, action: () => onNavTabClick?.(isCustomDefaultFeed ? SharedFeedPage.MyFeed : '/'), - icon: () => ( - - ), + icon: isV2 + ? (active: boolean) => ( + } /> + ) + : () => ( + + ), } : { title: 'Home', @@ -110,28 +116,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 ? { @@ -158,14 +167,6 @@ export const MainSection = ({ } /> ), }, - { - icon: (active: boolean) => ( - } /> - ), - title: 'Explore', - path: '/posts', - action: () => onNavTabClick?.(OtherFeedPage.Explore), - }, { icon: (active: boolean) => ( } /> @@ -196,6 +197,7 @@ export const MainSection = ({ isCustomDefaultFeed, isLoggedIn, isPlus, + isV2, onNavTabClick, showYearInReview, user, diff --git a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx index 0762ae8be2e..0aff28fb285 100644 --- a/packages/shared/src/components/sidebar/sections/NetworkSection.tsx +++ b/packages/shared/src/components/sidebar/sections/NetworkSection.tsx @@ -9,7 +9,7 @@ import { useSquadNavigation } from '../../../hooks'; import { useAuthContext } from '../../../contexts/AuthContext'; import { SquadImage } from '../../squads/SquadImage'; import { SidebarSettingsFlags } from '../../../graphql/settings'; -import { webappUrl } from '../../../lib/constants'; +import { squadCategoriesPaths, webappUrl } from '../../../lib/constants'; import type { SidebarSectionProps } from './common'; import { useSquadPendingPosts } from '../../../hooks/squads/useSquadPendingPosts'; import { Typography, TypographyColor } from '../../typography/Typography'; @@ -45,11 +45,22 @@ export const NetworkSection = ({ }; }) ?? []; return [ - isModeratorInAnySquad && - count > 0 && { - icon: () => } />, - title: 'Pending Posts', - path: `${webappUrl}squads/moderate`, + { + icon: (active: boolean) => ( + } /> + ), + title: 'Find Squads', + // Point at the actual landing page (`/squads` redirects here) + // so the active-state highlight matches the URL the user + // actually navigates to. + path: squadCategoriesPaths.discover, + isForcedLink: true, + }, + isModeratorInAnySquad && { + icon: () => } />, + title: 'Pending Posts', + path: `${webappUrl}squads/moderate`, + ...(count > 0 && { rightIcon: () => ( = 15 ? '15+' : count} ), - }, - { - icon: (active: boolean) => ( - } /> - ), - title: 'Find Squads', - path: `${webappUrl}squads`, - isForcedLink: true, + }), }, ...squadItems, ].filter(Boolean) as SidebarMenuItem[]; 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..c1eac84f548 --- /dev/null +++ b/packages/shared/src/components/sidebar/sections/ProfileSection.tsx @@ -0,0 +1,91 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import type { SidebarMenuItem } from '../common'; +import { ListIcon } from '../common'; +import { + AnalyticsIcon, + CoinIcon, + DevCardIcon, + JobIcon, + 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: 'Analytics', + path: `${webappUrl}analytics`, + 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: 'DevCard', + path: `${settingsUrl}/customization/devcard`, + 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 caf8dac341c..741bcb43a6d 100644 --- a/packages/shared/src/components/squads/SquadPageHeader.tsx +++ b/packages/shared/src/components/squads/SquadPageHeader.tsx @@ -38,6 +38,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]'; @@ -47,6 +53,7 @@ export function SquadPageHeader({ squad, members, shouldUseListMode, + hideHeaderBar = false, }: SquadPageHeaderProps): ReactElement { const { openModal } = useLazyModal(); const isSmartComposerEnabled = useSmartComposer(); @@ -159,7 +166,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/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index 30dfe8976bb..fe5172db8df 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -19,6 +19,7 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; import { IconSize, IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; +import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant'; interface ReadingStreakButtonProps { streak: UserStreak; @@ -26,6 +27,11 @@ interface ReadingStreakButtonProps { compact?: boolean; iconPosition?: ButtonIconPosition; className?: string; + // Portal the popup to so it escapes parents with `overflow:hidden` + // (e.g. the sidebar panel). Pair with `zIndex` so it stacks above the + // sidebar surface. + appendTooltipToBody?: boolean; + zIndex?: number; } interface CustomStreaksTooltipProps { @@ -34,6 +40,8 @@ interface CustomStreaksTooltipProps { shouldShowStreaks?: boolean; setShouldShowStreaks?: (value: boolean) => void; placement: TooltipPosition; + appendTooltipToBody?: boolean; + zIndex?: number; } function CustomStreaksTooltip({ @@ -42,6 +50,8 @@ function CustomStreaksTooltip({ shouldShowStreaks, setShouldShowStreaks, placement, + appendTooltipToBody, + zIndex, }: CustomStreaksTooltipProps): ReactElement { return ( document.body : undefined} + zIndex={zIndex} container={{ paddingClassName: 'p-0', bgClassName: 'bg-accent-pepper-subtlest', @@ -70,11 +82,14 @@ export function ReadingStreakButton({ compact, iconPosition, className, + appendTooltipToBody, + zIndex, }: ReadingStreakButtonProps): ReactElement { const { logEvent } = useLogContext(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); const isMobile = useViewSize(ViewSize.MobileL); + const { isV2 } = useLayoutVariant(); const [shouldShowStreaks, setShouldShowStreaks] = useState(false); const hasReadToday = streak?.lastViewAt && @@ -112,6 +127,8 @@ export function ReadingStreakButton({ shouldShowStreaks={shouldShowStreaks} setShouldShowStreaks={setShouldShowStreaks} placement={!isMobile && !isLaptop ? 'bottom-start' : 'bottom-end'} + appendTooltipToBody={appendTooltipToBody} + zIndex={zIndex} > {children} @@ -141,7 +158,7 @@ export function ReadingStreakButton({ : ButtonVariant.Float } onClick={handleToggle} - className={classnames('gap-1', className)} + className={classnames(isV2 ? 'gap-0.5' : 'gap-1', className)} size={!compact && !isMobile ? ButtonSize.Medium : ButtonSize.Small} > {streak?.current} 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..1c4db3daf7e 100644 --- a/packages/webapp/pages/[userId]/achievements.tsx +++ b/packages/webapp/pages/[userId]/achievements.tsx @@ -1,4 +1,4 @@ -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useContext } from 'react'; import { NextSeo } from 'next-seo'; import type { NextSeoProps } from 'next-seo/lib/types'; @@ -58,5 +58,9 @@ const ProfileAchievementsPage = ({ ); }; -ProfileAchievementsPage.getLayout = getProfileLayout; +ProfileAchievementsPage.getLayout = ( + page: ReactNode, + props: ProfileLayoutProps, +): ReactNode => + getProfileLayout(page, { ...props, pageHeaderTitle: 'Achievements' }); export default ProfileAchievementsPage; 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/daily-quests/index.tsx b/packages/webapp/pages/daily-quests/index.tsx new file mode 100644 index 00000000000..99c59106c4f --- /dev/null +++ b/packages/webapp/pages/daily-quests/index.tsx @@ -0,0 +1,70 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { NextSeoProps } from 'next-seo'; +import classNames from 'classnames'; +import { QuestButton } from '@dailydotdev/shared/src/components/quest/QuestButton'; +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 { + ResponsivePageContainer, + pageBorders, +} from '@dailydotdev/shared/src/components/utilities'; +import { + Typography, + TypographyColor, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; +import { getLayout } from '../../components/layouts/MainLayout'; +import { getPageSeoTitles } from '../../components/layouts/utils'; +import ProtectedPage from '../../components/ProtectedPage'; +import { defaultOpenGraph } from '../../next-seo'; + +const seoTitles = getPageSeoTitles('Daily quests'); +const seo: NextSeoProps = { + title: seoTitles.title, + openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph }, + description: + 'Complete your daily and weekly quests to earn rewards, climb levels, and keep your streak going.', + nofollow: true, + noindex: true, +}; + +function DailyQuestsPage(): ReactElement { + const { isV2 } = useLayoutVariant(); + const isV2Laptop = isV2; + + return ( + + {isV2Laptop && } +
+ {!isV2Laptop && ( + + + Daily quests + + + )} + + + +
+
+ ); +} + +const getDailyQuestsLayout: typeof getLayout = (...props) => + getFooterNavBarLayout(getLayout(...props)); + +DailyQuestsPage.getLayout = getDailyQuestsLayout; +DailyQuestsPage.layoutProps = { screenCentered: false, seo }; + +export default DailyQuestsPage; 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); @@ -259,124 +263,135 @@ const SourcePage = ({ const jsonLd = getSourcePageJsonLd(source); return ( - - -