diff --git a/packages/shared/src/components/BookmarkFeedLayout.tsx b/packages/shared/src/components/BookmarkFeedLayout.tsx index ef12cf9a813..a6e335e67fa 100644 --- a/packages/shared/src/components/BookmarkFeedLayout.tsx +++ b/packages/shared/src/components/BookmarkFeedLayout.tsx @@ -1,4 +1,4 @@ -import type { PropsWithChildren, ReactElement, ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import React, { useCallback, useContext, @@ -16,12 +16,12 @@ import { } from '../graphql/feed'; import { ClientQuestEventType } from '../graphql/quests'; import AuthContext from '../contexts/AuthContext'; -import { CustomFeedHeader, FeedPageHeader } from './utilities'; +import { CustomFeedHeader } from './utilities'; +import { PageHeader } from './layout/PageHeader'; import SearchEmptyScreen from './SearchEmptyScreen'; import type { FeedProps } from './Feed'; import Feed from './Feed'; import BookmarkEmptyScreen from './BookmarkEmptyScreen'; -import type { ButtonProps } from './buttons/Button'; import { Button, ButtonSize, ButtonVariant } from './buttons/Button'; import { ShareIcon, SortIcon } from './icons'; import { generateQueryKey, OtherFeedPage, RequestKey } from '../lib/query'; @@ -43,6 +43,11 @@ import { useTrackQuestClientEvent } from '../hooks/useTrackQuestClientEvent'; import { Dropdown } from './fields/Dropdown'; import { IconSize } from './Icon'; +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'; + export type BookmarkFeedLayoutProps = { isReminderOnly?: boolean; searchQuery?: string; @@ -60,17 +65,6 @@ const SharedBookmarksModal = dynamic( ), ); -const ShareBookmarksButton = ({ - children, - ...props -}: PropsWithChildren< - Pick, 'className' | 'onClick' | 'icon'> ->) => ( - -); - const bookmarkSortOptions = [ { label: 'Newest first', value: BookmarkSort.TimeDesc }, { label: 'Oldest first', value: BookmarkSort.TimeAsc }, @@ -190,54 +184,86 @@ export default function BookmarkFeedLayout({ return null; } + // Compose the header title slot: on laptop we inline the search field + // beside the title so the master header strip carries everything the + // user needs (title + search + actions). Mobile/tablet keep the + // search rendered as a row below the header. + const headerTitleSlot = ( +
+ + {title} + + {isLaptop && searchChildren && ( +
+ {searchChildren} +
+ )} +
+ ); + return ( {children} - - - {title} - - - - {searchChildren} + {!isSearchResults && ( } + icon={} iconOnly selectedIndex={selectedSort} options={bookmarkSortOptionLabels} onChange={(_, index) => setSelectedSort(index)} - buttonVariant={ButtonVariant.Float} - buttonSize={ButtonSize.Medium} + buttonVariant={ButtonVariant.Tertiary} + buttonSize={ButtonSize.Small} drawerProps={{ displayCloseButton: true }} /> )} {!isFolderPage && ( - } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + className={ + isLaptop ? compactTextButtonClassName : compactIconButtonClassName + } + icon={ + + } onClick={() => setShowSharedBookmarks(true)} > {isLaptop ? Share bookmarks : null} - + )} {folder && !isReminderOnly && ( )} - + + {!isLaptop && searchChildren && ( + + {searchChildren} + + )} {showSharedBookmarks && ( isHorizontal?: boolean; feedContainerRef?: React.Ref; disableListFrame?: boolean; - disableBriefCard?: boolean; } interface RankVariables { @@ -153,13 +146,6 @@ const ReaderPostModal = dynamic( ), ); -const BriefCardFeed = dynamic( - () => - import( - /* webpackChunkName: "briefCardFeed" */ './cards/brief/BriefCard/BriefCardFeed' - ), -); - const ProfileCompletionCard = dynamic( () => import( @@ -208,7 +194,6 @@ export default function Feed({ isHorizontal = false, feedContainerRef, disableListFrame = false, - disableBriefCard = false, }: FeedProps): ReactElement { const origin = Origin.Feed; const { logEvent } = useLogContext(); @@ -240,29 +225,9 @@ export default function Feed({ (marketingCta?.variant !== MarketingCtaVariant.BriefCard || !hasDismissBriefCta); const { isSearchPageLaptop } = useSearchResultsLayout(); - const hasNoBriefAction = - isActionsFetched && !checkHasCompleted(ActionType.GeneratedBrief); - - const { - showProfileCompletionCard, - isLoading: isProfileCompletionCardLoading, - } = useProfileCompletionCard({ isMyFeed }); - const hasDismissedBriefCard = - isActionsFetched && checkHasCompleted(ActionType.DismissBriefCard); + const { showProfileCompletionCard } = useProfileCompletionCard({ isMyFeed }); - const shouldEvaluateBriefCard = - isMyFeed && - hasNoBriefAction && - !hasDismissedBriefCard && - !showProfileCompletionCard && - !isProfileCompletionCardLoading && - !disableBriefCard; - const { value: briefCardFeatureValue } = useConditionalFeature({ - feature: briefCardFeedFeature, - shouldEvaluate: shouldEvaluateBriefCard, - }); - const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue; const [getProducts] = useUpdateQuery(getProductsQueryOptions()); const adTemplate = currentSettings.adTemplate ?? featureFeedAdTemplate.defaultValue?.default ?? { adStart: 1 }; @@ -315,7 +280,7 @@ export default function Feed({ const { onMenuClick, postMenuIndex, postMenuLocation } = useFeedContextMenu(); const useList = isListMode && numCards > 1; const virtualizedNumCards = useList ? 1 : numCards; - const showFirstSlotCard = showProfileCompletionCard || showBriefCard; + const showFirstSlotCard = showProfileCompletionCard; const { onOpenModal, onCloseModal, @@ -368,7 +333,6 @@ export default function Feed({ ); const { adjustedHeroInsertIndex, - shouldShowTopHero, shouldShowInFeedHero, title: readingReminderTitle, subtitle: readingReminderSubtitle, @@ -377,7 +341,7 @@ export default function Feed({ } = useReadingReminderFeedHero({ itemCount: items.length, itemsPerRow: virtualizedNumCards, - firstSlotOffset: Number(showProfileCompletionCard || showBriefCard), + firstSlotOffset: Number(showProfileCompletionCard), }); useMutationSubscription({ @@ -670,15 +634,6 @@ export default function Feed({ const containerProps = isSearchPageLaptop ? {} : { - topContent: shouldShowTopHero ? ( - onEnableHero(NotificationCtaPlacement.TopHero)} - onClose={() => onDismissHero(NotificationCtaPlacement.TopHero)} - /> - ) : undefined, header, inlineHeader, className, @@ -687,7 +642,6 @@ export default function Feed({ actionButtons, isHorizontal, feedContainerRef, - showBriefCard, disableListFrame, }; @@ -705,14 +659,6 @@ export default function Feed({ }} /> )} - {showBriefCard && !showProfileCompletionCard && ( - - )} {items.map((item, index) => ( void; hideFeedActionButtons?: boolean; - disableBriefCard?: boolean; } const getQueryBasedOnLogin = ( @@ -221,7 +220,6 @@ export default function MainFeedLayout({ isFinder, onNavTabClick, hideFeedActionButtons, - disableBriefCard, }: MainFeedLayoutProps): ReactElement { useScrollRestoration(); const { sortingEnabled, loadedSettings } = useContext(SettingsContext); @@ -725,16 +723,7 @@ export default function MainFeedLayout({ commentClassName={commentClassName} /> ) : ( - feedProps && ( - - ) + feedProps && )} {children} diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 8a95549255e..b727c9b5c8a 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, + NotificationTarget, + TargetType, + NotificationCtaPlacement, +} from '../lib/log'; import { PromptElement } from './modals/Prompt'; import { useNotificationParams } from '../hooks/useNotificationParams'; import { useAuthContext } from '../contexts/AuthContext'; @@ -24,7 +29,6 @@ import { ActiveFeedNameContextProvider, useActiveFeedNameContext, } from '../contexts'; -import { useFeedLayout, useViewSize, ViewSize } from '../hooks'; import { BootPopups } from './modals/BootPopups'; import { SmartComposerHotkey } from './post/SmartComposerHotkey'; import { SmartComposerDevToggle } from './post/SmartComposerDevToggle'; @@ -38,6 +42,8 @@ import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; import { SpotlightProvider } from './spotlight/SpotlightContext'; import { SpotlightHost } from './spotlight/SpotlightHost'; +import { TopHero } from './banners/HeroBottomBanner'; +import { useReadingReminderFeedHero } from '../hooks/notifications/useReadingReminderFeedHero'; const GoBackHeaderMobile = dynamic( () => @@ -76,7 +82,6 @@ function MainLayoutComponent({ isNavItemsButton, customBanner, additionalButtons, - screenCentered = true, showSidebar = true, className, onLogoClick, @@ -86,23 +91,38 @@ function MainLayoutComponent({ }: 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(); const { sidebarExpanded, autoDismissNotifications } = useContext(SettingsContext); + // The dual-sidebar layout takes ownership of the global header chrome + // (logo + search + user actions) for authenticated users on laptop+. + // When that's the case we hide the global header and switch the main + // content over to the floating card treatment (rounded, bordered, shadow) + // and hide the global feedback widget (the rail provides its own). + const sidebarOwnsHeader = isLoggedIn && showSidebar && sidebarRendered; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); const page = router?.route?.substring(1).trim() as SharedFeedPage; const currentFeedName = feedName ?? page ?? SharedFeedPage.Popular; const { isCustomFeed } = useFeedName({ feedName: currentFeedName }); const { plusEntryAnnouncementBar } = usePlusEntry(); - const isLaptopXL = useViewSize(ViewSize.LaptopXL); - const { screenCenteredOnMobileLayout } = useFeedLayout(); const { isNotificationsReady, unreadCount } = useNotificationContext(); useNotificationParams(); + const { + shouldShowTopHero, + title: readingReminderTitle, + subtitle: readingReminderSubtitle, + onEnableHero: onEnableReadingReminder, + onDismissHero: onDismissReadingReminder, + } = useReadingReminderFeedHero({ + itemCount: 0, + itemsPerRow: 1, + }); + useEffect(() => { if (!isNotificationsReady || unreadCount === 0 || hasLoggedImpression) { return; @@ -177,11 +197,8 @@ function MainLayoutComponent({ return null; } - const isScreenCentered = - isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; - return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -203,33 +220,67 @@ function MainLayoutComponent({
{isAuthReady && showSidebar && ( )} - {children} +
+ {shouldShowTopHero && ( + + onEnableReadingReminder(NotificationCtaPlacement.TopHero) + } + onClose={() => + onDismissReadingReminder(NotificationCtaPlacement.TopHero) + } + /> + )} +
+ {children} +
+
- {!hideFeedbackWidget && } + {!hideFeedbackWidget && !sidebarOwnsHeader && }
); } diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx index 96b6ccc3cb2..ea6dbca74ca 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx @@ -12,20 +12,14 @@ import { checkIsExtension } from '../../lib/func'; import { LogoutReason } from '../../lib/user'; import { TargetId } from '../../lib/log'; -import { ProfileMenuFooter } from './ProfileMenuFooter'; import { UpgradeToPlus } from '../UpgradeToPlus'; import { ProfileMenuHeader } from './ProfileMenuHeader'; +import { ProfileMenuStats } from './ProfileMenuStats'; import { HorizontalSeparator } from '../utilities'; import { ProfileSection } from './ProfileSection'; -import { ResourceSection } from './sections/ResourceSection'; -import { AccountSection } from './sections/AccountSection'; -import { MainSection } from './sections/MainSection'; -import { ThemeSection } from './sections/ThemeSection'; import { FeedbackButtonSection } from './sections/FeedbackButtonSection'; import { useCustomizeNewTabMenuItem } from './sections/ExtensionSection'; -import { ProfileCompletion } from '../../features/profile/components/ProfileWidgets/ProfileCompletion'; -import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; const ExtensionSection = dynamic(() => import( @@ -35,15 +29,15 @@ const ExtensionSection = dynamic(() => interface ProfileMenuProps { onClose: () => void; + position?: InteractivePopupPosition; } export default function ProfileMenu({ onClose, + position = InteractivePopupPosition.ProfileMenu, }: ProfileMenuProps): ReactElement | null { const { events } = useRouter(); const { user, logout } = useAuthContext(); - const { showIndicator: showProfileCompletion } = - useProfileCompletionIndicator(); const customizeMenuItem = useCustomizeNewTabMenuItem(onClose); useEffect(() => { @@ -58,15 +52,21 @@ export default function ProfileMenu({ return null; } + const logoutItem = { + title: 'Log out', + icon: ExitIcon, + onClick: () => logout(LogoutReason.ManualLogout), + }; + return ( - {showProfileCompletion && } + - - ); } diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx new file mode 100644 index 00000000000..42ea11c0960 --- /dev/null +++ b/packages/shared/src/components/ProfileMenu/ProfileMenuStats.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { ReputationUserBadge } from '../ReputationUserBadge'; +import { CoreIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import Link from '../utilities/Link'; +import { walletUrl } from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; + +export const ProfileMenuStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const hasCoresAccess = useHasAccessToCores(); + + if (!user) { + return null; + } + + if (!hasCoresAccess && !user.reputation) { + return null; + } + + return ( +
+ + {hasCoresAccess && ( + + + + {largeNumberFormat(user.balance?.amount ?? 0)} + + + )} +
+ ); +}; diff --git a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx b/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx deleted file mode 100644 index cadda4998fd..00000000000 --- a/packages/shared/src/components/ProfileMenu/sections/AccountSection.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; -import { ProfileSection } from '../ProfileSection'; -import { - CreditCardIcon, - InviteIcon, - SettingsIcon, - TrendingIcon, - OrganizationIcon, -} from '../../icons'; -import { settingsUrl } from '../../../lib/constants'; -import { useLazyModal } from '../../../hooks/useLazyModal'; -import { LazyModal } from '../../modals/common/types'; -import { useCanPurchaseCores } from '../../../hooks/useCoresFeature'; -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; - -type AccountSectionProps = { - /** - * Optional item rendered at the top of the section, above "Settings". - * Used by ProfileMenu to surface the "Customize new tab" entry inline - * so it sits inside the same visual block as Settings instead of in a - * separate section. - */ - prepended?: ProfileSectionItemProps | null; -}; - -export const AccountSection = ({ - prepended, -}: AccountSectionProps = {}): ReactElement => { - const { openModal } = useLazyModal(); - const canBuy = useCanPurchaseCores(); - - const items: ProfileSectionItemProps[] = [ - ...(prepended ? [prepended] : []), - { - title: 'Settings', - href: `${settingsUrl}/profile`, - icon: SettingsIcon, - }, - { - title: 'Subscriptions', - href: `${settingsUrl}/subscription`, - icon: CreditCardIcon, - }, - { - title: 'Organizations', - href: `${settingsUrl}/organization`, - icon: OrganizationIcon, - }, - { - title: 'Invite friends', - href: `${settingsUrl}/invite`, - icon: InviteIcon, - }, - ]; - - if (canBuy) { - items.push({ - title: 'Ads dashboard', - icon: TrendingIcon, - onClick: () => { - openModal({ type: LazyModal.AdsDashboard }); - }, - }); - } - - return ; -}; diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index b1d60cbb38a..b88fb29f78f 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { HorizontalSeparator } from '../../utilities'; import { ProfileSection } from '../ProfileSection'; import { useDndContext } from '../../../contexts/DndContext'; import { useSettingsContext } from '../../../contexts/SettingsContext'; @@ -21,19 +20,10 @@ import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../lib/log'; import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -export type ExtensionSectionProps = { - /** - * Called after the user picks any item in this section so the parent - * ProfileMenu collapses the dropdown. - */ - onClose?: () => void; -}; - /** * Hook returning the "Customize new tab" item when the feature flag is - * on, or `null` when it isn't. AccountSection consumes this to prepend - * the entry directly above "Settings" — placing it inside the existing - * section instead of giving it its own visual block. + * on, or `null` when it isn't. The ProfileMenu renders the entry as its + * own one-item section, sitting above the legacy ExtensionSection block. */ export const useCustomizeNewTabMenuItem = ( onClose?: () => void, @@ -63,8 +53,8 @@ export const useCustomizeNewTabMenuItem = ( /** * Legacy fallback for users in the control bucket of the customize * sidebar feature flag. When the flag is on, the customize entry is - * folded into AccountSection via `useCustomizeNewTabMenuItem` and this - * component renders nothing. + * surfaced via `useCustomizeNewTabMenuItem` and this component renders + * nothing. */ export const ExtensionSection = (): ReactElement | null => { const { isEnabled: isCustomizerEnabled } = useCustomizeNewTab(); @@ -81,27 +71,24 @@ export const ExtensionSection = (): ReactElement | null => { } return ( - <> - - openModal({ type: shortcutsModal }), - }, - { - title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, - icon: isDndActive ? PlayIcon : PauseIcon, - onClick: () => setShowDnd?.(true), - }, - { - title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, - icon: () => , - onClick: () => toggleOptOutCompanion(), - }, - ]} - /> - + openModal({ type: shortcutsModal }), + }, + { + title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, + icon: isDndActive ? PlayIcon : PauseIcon, + onClick: () => setShowDnd?.(true), + }, + { + title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, + icon: () => , + onClick: () => toggleOptOutCompanion(), + }, + ]} + /> ); }; diff --git a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx b/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx deleted file mode 100644 index 7ee1b64eab9..00000000000 --- a/packages/shared/src/components/ProfileMenu/sections/MainSection.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import type { ReactElement } from 'react'; - -import { ProfileSection } from '../ProfileSection'; -import type { ProfileSectionItemProps } from '../ProfileSectionItem'; -import { - AnalyticsIcon, - CoinIcon, - DevCardIcon, - MedalBadgeIcon, - UserIcon, -} from '../../icons'; -import { settingsUrl, walletUrl, webappUrl } from '../../../lib/constants'; -import { useAuthContext } from '../../../contexts/AuthContext'; -import { useHasAccessToCores } from '../../../hooks/useCoresFeature'; - -export const MainSection = (): ReactElement => { - const hasAccessToCores = useHasAccessToCores(); - const { user } = useAuthContext(); - - const items: ProfileSectionItemProps[] = [ - { - title: 'Your profile', - href: `${webappUrl}${user?.username}`, - icon: UserIcon, - }, - ...(hasAccessToCores - ? [ - { - title: 'Core wallet', - href: walletUrl, - icon: CoinIcon, - } satisfies ProfileSectionItemProps, - ] - : []), - { - title: 'Achievements', - href: `${webappUrl}${user?.username}/achievements`, - icon: MedalBadgeIcon, - }, - { - title: 'DevCard', - href: `${settingsUrl}/customization/devcard`, - icon: DevCardIcon, - }, - { - title: 'Analytics', - href: `${webappUrl}analytics`, - icon: AnalyticsIcon, - }, - ]; - - return ; -}; diff --git a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx index 99519cae038..4ce1916e245 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ResourceSection.tsx @@ -4,25 +4,34 @@ import type { ReactElement } from 'react'; import { ProfileSection } from '../ProfileSection'; import { DocsIcon, - FeedbackIcon, MegaphoneIcon, - TerminalIcon, + PhoneIcon, + PrivacyIcon, + ReputationLightningIcon, } from '../../icons'; import { + appsUrl, businessWebsiteUrl, docs, - feedback, - webappUrl, + reputation, + settingsUrl, } from '../../../lib/constants'; export const ResourceSection = (): ReactElement => { return ( { external: true, }, { - title: 'Docs', - icon: DocsIcon, - href: docs, + title: 'Apps', + icon: PhoneIcon, + href: appsUrl, external: true, }, { - title: 'Support', - icon: FeedbackIcon, - href: feedback, + title: 'Docs', + icon: DocsIcon, + href: docs, external: true, }, ]} diff --git a/packages/shared/src/components/banners/HeroBottomBanner.tsx b/packages/shared/src/components/banners/HeroBottomBanner.tsx index 015c9681f04..d34e1b230e8 100644 --- a/packages/shared/src/components/banners/HeroBottomBanner.tsx +++ b/packages/shared/src/components/banners/HeroBottomBanner.tsx @@ -1,9 +1,13 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; import React from 'react'; -import { Button, ButtonVariant } from '../buttons/Button'; -import { MiniCloseIcon } from '../icons'; -import feedStyles from '../Feed.module.css'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import CloseButton from '../CloseButton'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../typography/Typography'; import ReadingReminderCatLaptop from './ReadingReminderCatLaptop'; type TopHeroProps = { @@ -23,43 +27,40 @@ export const TopHero = ({ }: TopHeroProps): ReactElement => { return (
-
-
-
-
-
-
- -
-
-
- -
-
+ +
+
+ + {title} + + + {subtitle} +
+
+
); }; diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 7fa5060f681..c8db60d0008 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -16,6 +16,7 @@ import { SidebarSettingsFlags } from '../../graphql/settings'; import { useLogContext } from '../../contexts/LogContext'; import type { Origin } from '../../lib/log'; import { LogEvent, TargetId } from '../../lib/log'; +import type { IconProps } from '../Icon'; import { useActiveFeedContext } from '../../contexts/ActiveFeedContext'; import { useAuthContext } from '../../contexts/AuthContext'; import { webappUrl } from '../../lib/constants'; @@ -25,9 +26,19 @@ import { Tooltip } from '../tooltip/Tooltip'; export const ToggleClickbaitShield = ({ origin, buttonProps = {}, + iconButtonProps, + iconSize, }: { origin: Origin; + /** Applied to both render paths (Plus icon-only and non-Plus icon+text). */ buttonProps?: ButtonProps<'button'>; + /** + * Applied only to the Plus (icon-only) render path on top of `buttonProps`. + * Use to keep the icon-only case sized like sibling icon-buttons in compact + * headers without forcing min-widths on the non-Plus "X/Y" text variant. + */ + iconButtonProps?: ButtonProps<'button'>; + iconSize?: IconProps['size']; }): ReactElement => { const queryClient = useQueryClient(); const { queryKey: feedQueryKey } = useActiveFeedContext(); @@ -62,9 +73,12 @@ export const ToggleClickbaitShield = ({ {...commonIconProps} icon={ hasUsedFreeTrial ? ( - + ) : ( - + ) } onClick={() => { @@ -91,11 +105,12 @@ export const ToggleClickbaitShield = ({ > + ); +}; diff --git a/packages/shared/src/components/cards/common/Card.tsx b/packages/shared/src/components/cards/common/Card.tsx index c3ccaf13ada..7a616689716 100644 --- a/packages/shared/src/components/cards/common/Card.tsx +++ b/packages/shared/src/components/cards/common/Card.tsx @@ -50,7 +50,7 @@ const clickableCardClasses = classNames( export const CardLink = classed('a', clickableCardClasses); const cardClassess = - 'snap-start relative max-h-cardLarge h-full flex flex-col p-0 rounded-16 border border-border-subtlest-tertiary hover:border-border-subtlest-secondary bg-background-subtle'; + 'snap-start relative max-h-cardLarge h-full flex flex-col p-0 rounded-16 border border-border-subtlest-quaternary hover:border-border-subtlest-tertiary bg-background-subtle'; export const Card = classed('article', styles.card, cardClassess); @@ -71,7 +71,7 @@ export const CardHeader = classed( export const ListCard = classed( 'article', styles.card, - 'relative flex items-stretch pt-4 pb-3 pr-4 rounded-16 bg-background-subtle border border-border-subtlest-tertiary hover:border-border-subtlest-secondary shadow-2', + 'relative flex items-stretch pt-4 pb-3 pr-4 rounded-16 bg-background-subtle border border-border-subtlest-quaternary hover:border-border-subtlest-tertiary shadow-2', ); export const ListCardDivider = classed( diff --git a/packages/shared/src/components/cards/common/list/ListCard.tsx b/packages/shared/src/components/cards/common/list/ListCard.tsx index c95db849119..69e98759364 100644 --- a/packages/shared/src/components/cards/common/list/ListCard.tsx +++ b/packages/shared/src/components/cards/common/list/ListCard.tsx @@ -51,7 +51,7 @@ export const CardLink = classed('a', clickableCardClasses); export const ListCard = classed( 'article', - `group relative w-full flex flex-col py-6 px-4 border-t border-border-subtlest-tertiary rounded-16 + `group relative w-full flex flex-col py-6 px-4 border-t border-border-subtlest-quaternary rounded-16 hover:bg-surface-float `, ); diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index 21a691f3c91..c02d5110e4f 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -1,15 +1,23 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; import { getDayOfYear } from 'date-fns'; import { Button, ButtonVariant, ButtonSize } from '../buttons/Button'; import { useAuthContext } from '../../contexts/AuthContext'; import { useSettingsContext } from '../../contexts/SettingsContext'; +import { useLogContext } from '../../contexts/LogContext'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; import { useCustomizeNewTab } from '../../features/customizeNewTab/CustomizeNewTabContext'; +import { IconSize } from '../Icon'; +import { MiniCloseIcon } from '../icons'; +import { LogEvent, TargetType } from '../../lib/log'; + +interface FeedbackWidgetProps { + placement?: 'fixed' | 'sidebar' | 'support'; +} const TEAM_MEMBERS = [ { @@ -68,19 +76,25 @@ const getDailyTrio = (): ReadonlyArray<(typeof TEAM_MEMBERS)[number]> => { return [0, 1, 2].map((i) => TEAM_MEMBERS[(dayOfYear + i) % len]); }; -export function FeedbackWidget(): ReactElement | null { +export function FeedbackWidget({ + placement = 'fixed', +}: FeedbackWidgetProps): ReactElement | null { const { user } = useAuthContext(); - const { showFeedbackButton } = useSettingsContext(); + const { showFeedbackButton, toggleShowFeedbackButton } = useSettingsContext(); + const { logEvent } = useLogContext(); const isMobile = useViewSize(ViewSize.MobileL); const { openModal } = useLazyModal(); const dailyTrio = useMemo(getDailyTrio, []); const [isCompact, setIsCompact] = useState(false); const { panelWidth } = useCustomizeNewTab(); - // Only show for authenticated users on desktop when the setting is on. - // Mobile feedback is handled by FooterPlusButton. Hide during the - // panel rather than a competing pill in the corner. - const isVisible = !!user && !isMobile && showFeedbackButton; + // Only show for authenticated users on desktop. The sidebar variant is + // additionally gated by the `showFeedbackButton` setting; the support-menu + // variant is always available so users can re-open the widget after + // dismissing it from the sidebar. + const isSupport = placement === 'support'; + const isSidebar = placement === 'sidebar'; + const isVisible = !!user && !isMobile && (isSupport || showFeedbackButton); useEffect(() => { if (!isVisible) { @@ -96,12 +110,69 @@ export function FeedbackWidget(): ReactElement | null { return () => window.removeEventListener('scroll', callback); }, [isVisible]); + const onHideFeedbackButton = useCallback(() => { + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.FeedbackButton, + target_id: 'hide', + }); + return toggleShowFeedbackButton(); + }, [logEvent, toggleShowFeedbackButton]); + if (!isVisible) { return null; } + if (isSidebar || isSupport) { + return ( +
+ + + {isSidebar && ( + + )} +
+ ); + } + return ( + {feedSettingsButtonLabel} + + ) : ( + + {feedSettingsButtonLabel} + + )} + {showToggleShortcuts && ( + <> + {shouldUseListFeedLayout ? ( + + ) : ( + + )} + )} ); diff --git a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx index c6b0366b18e..540d8159f1f 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.spec.tsx @@ -14,6 +14,7 @@ import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { useScrollTopClassName } from '../../hooks/useScrollTopClassName'; import { useFeedName } from '../../hooks/feed/useFeedName'; import useActiveNav from '../../hooks/useActiveNav'; +import { useAuthContext } from '../../contexts/AuthContext'; jest.mock('next/dynamic', () => () => { return function MockDynamicComponent() { @@ -54,6 +55,10 @@ jest.mock('../../hooks/feed/useFeedName', () => ({ jest.mock('../../hooks/useActiveNav', () => jest.fn()); +jest.mock('../../contexts/AuthContext', () => ({ + useAuthContext: jest.fn(), +})); + jest.mock('../header/MobileExploreHeader', () => ({ MobileExploreHeader: ({ path }: { path: string }) => (
{path}
@@ -68,6 +73,7 @@ const mockUseFeatureTheme = useFeatureTheme as jest.Mock; const mockUseScrollTopClassName = useScrollTopClassName as jest.Mock; const mockUseFeedName = useFeedName as jest.Mock; const mockUseActiveNav = useActiveNav as jest.Mock; +const mockUseAuthContext = useAuthContext as jest.Mock; describe('MainLayoutHeader', () => { beforeEach(() => { @@ -84,6 +90,7 @@ describe('MainLayoutHeader', () => { isSearch: false, }); mockUseActiveNav.mockReturnValue({ profile: false }); + mockUseAuthContext.mockReturnValue({ isLoggedIn: false }); }); afterEach(() => { diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index ec13ee1791c..e6253b66d6b 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -17,6 +17,7 @@ import FeedNav from '../feeds/FeedNav'; import { MobileExploreHeader } from '../header/MobileExploreHeader'; import useActiveNav from '../../hooks/useActiveNav'; import { SpotlightTrigger } from '../spotlight/SpotlightTrigger'; +import { useAuthContext } from '../../contexts/AuthContext'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -35,12 +36,8 @@ function MainLayoutHeader({ sidebarRendered, additionalButtons, onLogoClick, -}: MainLayoutHeaderProps): ReactElement { +}: MainLayoutHeaderProps): ReactElement | null { const { loadedSettings } = useSettingsContext(); - // Header is `fixed` so it escapes the parent's `padding-right`. Read - // the customize sidebar width directly here and shrink the header to - // match — keeps it from sliding under the panel and lets it animate - // alongside the feed. const { panelWidth } = useCustomizeNewTab(); const [hasHydrated, setHasHydrated] = useState(false); const { streak, isStreaksEnabled } = useReadingStreak(); @@ -62,6 +59,14 @@ function MainLayoutHeader({ shouldUseLoadedSettings && isMobile && isSearchPage; const shouldRenderFeedNav = shouldUseLoadedSettings && isMobile && !isSearchPage; + const { isLoggedIn } = useAuthContext(); + // The dual-sidebar layout owns the logo, search, and action buttons on + // laptop+ for authenticated users. Skip the global header entirely so + // chrome isn't duplicated and the content card can sit flush to the top. + // Logged-out users still see the header because the sidebar doesn't + // surface a login control. + const shouldHideForSidebar = isLaptop && !!sidebarRendered && isLoggedIn; + const customizerWidth = panelWidth ? `${panelWidth}px` : '0px'; useEffect(() => { setHasHydrated(true); @@ -87,6 +92,10 @@ function MainLayoutHeader({ ); }, [shouldUseLoadedSettings, isSearchPage, hasBanner]); + if (shouldHideForSidebar) { + return null; + } + if (shouldRenderFeedNav) { return ( <> @@ -111,9 +120,9 @@ function MainLayoutHeader({ style={{ ...(featureTheme ? featureTheme.navbar : undefined), right: panelWidth || undefined, - width: panelWidth ? `calc(100% - ${panelWidth}px)` : undefined, + width: panelWidth ? `calc(100% - ${customizerWidth})` : undefined, transition: panelWidth - ? 'right 200ms ease-in-out, width 200ms ease-in-out' + ? 'right 200ms ease-in-out, width 300ms ease-in-out' : undefined, }} > diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx new file mode 100644 index 00000000000..836a892178f --- /dev/null +++ b/packages/shared/src/components/layout/PageHeader.tsx @@ -0,0 +1,68 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; + +// Visual shell of the page-header strip. Exported so callers that +// need a custom internal layout (e.g. wide horizontal tabs that +// shouldn't be locked inside the title/actions slot) can compose +// their own `
` without duplicating the styling. +// +// `min-h-14` (56px = a Small button + py-3 on each side) locks the +// strip to a consistent height across pages so a header with action +// buttons (Profile's Save, API's Create token, ...) is the same +// height as a header that only renders a title (Content sources, +// Tags, ...). Without this, navigating between settings pages +// caused a 12px vertical shift of the page content below. +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 { + /** + * Left-side title. A plain string is wrapped in a bold callout + * (matching the homepage feed header). For custom typography pass + * a ReactNode and it will render inside a truncating flex slot. + */ + title?: ReactNode; + /** + * Right-side actions (buttons, dropdowns, etc.). Docked to the + * end of the row with shrink-0 so the title takes the remaining + * space and truncates first. + */ + children?: ReactNode; + className?: string; +} + +/** + * Shared "page header" strip used at the top of the floating card on + * every primary page (home, squads, bookmarks, game center, ...). + * Matches the homepage feed list-frame: bottom border, px-6 py-3, + * title on the left, action buttons docked right. + */ +export const PageHeader = ({ + title, + children, + className, +}: PageHeaderProps): ReactElement => ( +
+ {title !== undefined && + (typeof title === 'string' ? ( + + {title} + + ) : ( + // ReactNode titles handle their own typography + overflow. + // Notably, no `truncate` wrapper here so callers can render + // full-height tab navigation (with bottom-aligned underlines) + // inside the title slot — the homepage feed-cards header was + // designed around composable left-side content, not just text. +
+ {title} +
+ ))} + {children !== undefined && ( +
+ {children} +
+ )} +
+); diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 41b5d0c98f7..ab6528e89c7 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -33,8 +33,8 @@ import { QueryStateKeys, useQueryState } from '../../hooks/utils/useQueryState'; import type { AllowedTags, TypographyProps } from '../typography/Typography'; import { Typography } from '../typography/Typography'; import { ToggleClickbaitShield } from '../buttons/ToggleClickbaitShield'; +import { BriefShortcutButton } from '../cards/brief/BriefShortcutButton'; import { LogEvent, Origin } from '../../lib/log'; -import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import { ActionType } from '../../graphql/actions'; import { BrowserName, @@ -102,17 +102,22 @@ export const SearchControlHeader = ({ return null; } + 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: compactIconButtonClassName, container: 'flex', }, shouldIndicateSelected: true, - buttonSize: isMobile ? ButtonSize.Small : ButtonSize.Medium, + buttonSize: ButtonSize.Small, iconOnly: true, - buttonVariant: isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary, + buttonVariant: ButtonVariant.Tertiary, }; const hasDismissedInstallExtension = checkHasCompleted( @@ -131,12 +136,18 @@ export const SearchControlHeader = ({ key="install-extension" tag="a" href={downloadBrowserExtension} - variant={isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary} - size={ButtonSize.Medium} - icon={isEdge ? : } + variant={ButtonVariant.Tertiary} + size={ButtonSize.Small} + icon={ + isEdge ? ( + + ) : ( + + ) + } rel={anchorDefaultRel} target="_blank" - className="ml-auto" + className={isLaptop ? compactTextButtonClassName : undefined} onClick={() => logEvent({ event_name: LogEvent.DownloadExtension, @@ -149,19 +160,39 @@ export const SearchControlHeader = ({ -
- -
+ return ( +
+
+ +
- {userAchievement.progress}/{target} + {userAchievement.achievement.name} - {userAchievement.achievement.points} pts + {userAchievement.achievement.description}
- + +
+ +
+ + {userAchievement.progress}/{target} + + + {userAchievement.achievement.points} pts +
- ); - })} -
- )} + +
+ ); + })} + + )} + + ); +}; + +export const AchievementPickerModal = ({ + achievements, + trackedAchievementId, + onTrack, + onUntrack, + onRequestClose, + ...props +}: AchievementPickerModalProps): ReactElement => { + return ( + + + + ); diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 1122dc10e27..9afc5d799ec 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,8 +13,15 @@ import { webappUrl } from '../../lib/constants'; import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; +import { IconSize } from '../Icon'; -function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { +function NotificationsBell({ + compact, + rail, +}: { + compact?: boolean; + rail?: boolean; +}): ReactElement { const router = useRouter(); const atNotificationsPage = router.pathname === notificationsUrl; const { logEvent } = useLogContext(); @@ -31,6 +38,39 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option; + if (rail) { + return ( + +
+ + + + {hasNotification && ( + + {getUnreadText(unreadCount)} + + )} + + +
+
+ ); + } + return (
diff --git a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx index e026266c694..f2ccf551875 100644 --- a/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx +++ b/packages/shared/src/components/opportunity/OpportunityEntryButton.tsx @@ -51,16 +51,14 @@ export const OpportunityEntryButton = () => { hasOpportunityAlert && hasNotClickedOpportunity ? OpportunityTooltip : SimpleTooltip; + const href = `${webappUrl}jobs/${ + hasOpportunityAlert ? alerts.opportunityId : '' + }`; return (
- + - - -
-
- ); -}; diff --git a/packages/shared/src/components/profile/ProfileButton.spec.tsx b/packages/shared/src/components/profile/ProfileButton.spec.tsx index 35571e16823..83cb4f8beea 100644 --- a/packages/shared/src/components/profile/ProfileButton.spec.tsx +++ b/packages/shared/src/components/profile/ProfileButton.spec.tsx @@ -60,22 +60,6 @@ it('should show "Reputation" tooltip on the reputation badge', () => { expect(screen.getByLabelText('Reputation')).toBeInTheDocument(); }); -it('should show settings option that opens modal', async () => { - renderComponent(); - - const profileBtn = await screen.findByRole('button', { - name: 'Profile settings', - }); - await act(async () => { - profileBtn.click(); - }); - - const settingsButton = await screen.findByRole('link', { - name: 'Settings', - }); - expect(settingsButton).toBeInTheDocument(); -}); - it('should click the logout button and logout', async () => { renderComponent(); diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index 08c4675e293..f1a25e79e72 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -4,7 +4,9 @@ import classNames from 'classnames'; import dynamic from 'next/dynamic'; import { useAuthContext } from '../../contexts/AuthContext'; import { ProfilePictureWithIndicator } from './ProfilePictureWithIndicator'; -import { CoreIcon, SettingsIcon } from '../icons'; +import { ProfileImageSize } from '../ProfilePicture'; +import { ArrowIcon, CoreIcon, SettingsIcon } from '../icons'; +import { InteractivePopupPosition } from '../tooltips/InteractivePopup'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { useInteractivePopup } from '../../hooks/utils/useInteractivePopup'; import { ReputationUserBadge } from '../ReputationUserBadge'; @@ -28,11 +30,15 @@ const ProfileMenu = dynamic( interface ProfileButtonProps { className?: string; + avatarOnly?: boolean; + compact?: boolean; settingsIconOnly?: boolean; } export default function ProfileButton({ + avatarOnly, className, + compact, settingsIconOnly, }: ProfileButtonProps): ReactElement { const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); @@ -59,7 +65,6 @@ export default function ProfileButton({ typeof animatedReputation === 'number' ? animatedReputation : user?.reputation; - const preciseBalance = formatCurrency(displayedBalance, { minimumFractionDigits: 0, }); @@ -197,83 +202,154 @@ export default function ProfileButton({ return <>; } - return ( - <> - {settingsIconOnly ? ( + const renderTrigger = (): ReactElement => { + if (settingsIconOnly) { + return ( - - -
+ onClick={wrapHandler(() => onUpdate(!isOpen))} + > + +
+ +
+
+ + ); + } + + if (compact) { + return ( + + ); + } + + return ( +
+ {isStreaksEnabled && streak && ( + + )} + {hasCoresAccess && ( + + Wallet +
+ {preciseBalance} Cores + + } > - - - - -
- -
-
- -
+ + + + + + )} + + + ); + }; + + return ( + <> + {renderTrigger()} + {isOpen && ( + onUpdate(false)} + position={ + compact + ? InteractivePopupPosition.SidebarProfileMenu + : InteractivePopupPosition.ProfileMenu + } + /> )} - {isOpen && onUpdate(false)} />} ); } diff --git a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx index a1b2dd22d3b..668e98eb3fa 100644 --- a/packages/shared/src/components/profile/ProfileSettingsMenu.tsx +++ b/packages/shared/src/components/profile/ProfileSettingsMenu.tsx @@ -6,16 +6,10 @@ import { AddUserIcon, BellIcon, EditIcon, - DevCardIcon, EmbedIcon, - DocsIcon, - FeedbackIcon, AppIcon, - PrivacyIcon, - MegaphoneIcon, UserIcon, BlockIcon, - CoinIcon, CreditCardIcon, HashtagIcon, HotIcon, @@ -24,8 +18,6 @@ import { MailIcon, EyeIcon, NewTabIcon, - PhoneIcon, - ReputationLightningIcon, ExitIcon, OrganizationIcon, TrendingIcon, @@ -33,18 +25,9 @@ import { TerminalIcon, TourIcon, FeatherIcon, - JoystickIcon, } from '../icons'; import { NavDrawer } from '../drawers/NavDrawer'; -import { - appsUrl, - businessWebsiteUrl, - docs, - reputation, - settingsUrl, - walletUrl, - webappUrl, -} from '../../lib/constants'; +import { settingsUrl, webappUrl } from '../../lib/constants'; import type { ProfileSectionItemProps, @@ -57,17 +40,16 @@ import type { WithClassNameProps } from '../utilities'; import { HorizontalSeparator } from '../utilities'; import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme'; import { ProfileMenuHeader } from '../ProfileMenu/ProfileMenuHeader'; +import { ThemeSection } from '../ProfileMenu/sections/ThemeSection'; import { ProfileImageSize } from '../ProfilePicture'; import { useViewSize, ViewSize } from '../../hooks'; import { TypographyColor, TypographyType } from '../typography/Typography'; -import { useHasAccessToCores } from '../../hooks/useCoresFeature'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, TargetId } from '../../lib/log'; import { VolunteeringIcon } from '../icons/Volunteering'; import { GraduationIcon } from '../icons/Graduation'; -import { MedalBadgeIcon } from '../icons/MedalBadge'; import { MedalIcon } from '../icons/Medal'; type MenuItems = Record< @@ -83,7 +65,6 @@ const defineMenuItems = (items: T): T => items; const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { const { openModal } = useLazyModal(); const { logEvent } = useLogContext(); - const { user } = useAuthContext(); const items = useMemo( () => @@ -207,12 +188,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { playground: { title: 'Gamification', items: { - gameCenter: { - title: 'Game Center', - icon: JoystickIcon, - href: `${webappUrl}game-center`, - external: true, - }, + // Game Center, Achievements, and DevCard are intentionally + // omitted here — they live in the sidebar Profile rail. The + // settings menu is for settings, not duplicate dashboard links. gamification: { title: 'Feature visibility', icon: EyeIcon, @@ -223,12 +201,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: HotIcon, href: `${settingsUrl}/customization/streaks`, }, - achievements: { - title: 'Achievements', - icon: MedalBadgeIcon, - href: `${webappUrl}${user?.username}/achievements`, - external: true, - }, hotTakes: { title: 'Hot Takes', icon: HotIcon, @@ -239,11 +211,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { onClose?.(); }, }, - devcard: { - title: 'DevCard', - icon: DevCardIcon, - href: `${settingsUrl}/customization/devcard`, - }, }, }, customization: { @@ -274,12 +241,9 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { icon: OrganizationIcon, href: `${settingsUrl}/organization`, }, - coreWallet: { - title: 'Core Wallet', - icon: CoinIcon, - href: walletUrl, - external: true, - }, + // Core wallet lives in the sidebar Profile rail (when the user + // has access). Removed from the settings menu to avoid duplicate + // destinations. adsDashboard: { title: 'Ads dashboard', icon: TrendingIcon, @@ -287,45 +251,6 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { } as ProfileSectionItemPropsWithoutHref, }, }, - help: { - title: 'Help center', - items: { - feedback: { - title: 'Your Feedback', - icon: FeedbackIcon, - href: `${settingsUrl}/feedback`, - }, - privacy: { - title: 'Privacy', - icon: PrivacyIcon, - href: `${settingsUrl}/privacy`, - }, - reputation: { - title: 'Reputation', - icon: ReputationLightningIcon, - href: reputation, - external: true, - }, - advertise: { - title: 'Advertise', - icon: MegaphoneIcon, - href: businessWebsiteUrl, - external: true, - }, - apps: { - title: 'Apps', - icon: PhoneIcon, - href: appsUrl, - external: true, - }, - docs: { - title: 'Docs', - icon: DocsIcon, - href: docs, - external: true, - }, - }, - }, logout: { title: null, items: { @@ -337,7 +262,7 @@ const useAccountPageItems = ({ onClose }: { onClose?: () => void } = {}) => { }, }, }), - [logEvent, onClose, openModal, user?.username], + [logEvent, onClose, openModal], ); return { items }; @@ -355,11 +280,12 @@ export const InnerProfileSettingsMenu = ({ }: WithClassNameProps & { onClose?: () => void }) => { const { asPath } = useRouter(); const isMobile = useViewSize(ViewSize.MobileL); - const hasAccessToCores = useHasAccessToCores(); const { items: accountPageItems } = useAccountPageItems({ onClose }); return ( +); + const AccountNotificationsPage = (): ReactElement => { const { isLoadingPreferences } = useNotificationSettings(); + const [activeTab, setActiveTab] = useState('in-app'); + const isLaptop = useViewSize(ViewSize.Laptop); + + // Laptop: tabs replace the page title in the master PageHeader + // strip (matches FindSquad's directory navbar pattern). The `-my-3` + // shell cancels the header's vertical padding so the tab underline + // lands flush on the header's bottom border. + // Mobile / tablet: keep a plain "Notifications" title in the + // AccountPageHeading and stack the tab nav below it, since the + // mobile heading row is too short for a full tab strip with an + // underline indicator. + const title = isLaptop ? ( +
+ +
+ ) : ( + 'Notifications' + ); + + const body = + activeTab === 'in-app' ? ( + + ) : ( + + ); if (isLoadingPreferences) { - return
; + return ( + +
+ + ); } return ( - - - - - - - - - - + + {!isLaptop && ( +
+ +
+ )} + {body} +
); }; diff --git a/packages/webapp/pages/settings/profile/experience/edit.tsx b/packages/webapp/pages/settings/profile/experience/edit.tsx index d1acb8892dd..3c3595ba4c7 100644 --- a/packages/webapp/pages/settings/profile/experience/edit.tsx +++ b/packages/webapp/pages/settings/profile/experience/edit.tsx @@ -198,12 +198,13 @@ const Page = ({ experience }: PageProps): ReactElement => { }`} actions={ diff --git a/packages/webapp/pages/sources/[source].tsx b/packages/webapp/pages/sources/[source].tsx index dd735255ed5..82fa55e5049 100644 --- a/packages/webapp/pages/sources/[source].tsx +++ b/packages/webapp/pages/sources/[source].tsx @@ -66,6 +66,8 @@ import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; import { ArchiveEntryCard } from '@dailydotdev/shared/src/components/archive/ArchiveEntryCard'; import { ArchiveBreadcrumbs } from '@dailydotdev/shared/src/components/archive/ArchiveBreadcrumbs'; import { ArchiveScopeType } from '@dailydotdev/shared/src/graphql/archive'; +import { useRecordRecentSourceVisit } from '@dailydotdev/shared/src/hooks/feed/useRecentPages'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import Custom404 from '../404'; import { defaultOpenGraph, defaultSeo } from '../../next-seo'; import { mainFeedLayoutProps } from '../../components/layouts/MainFeedPage'; @@ -220,6 +222,7 @@ const SourcePage = ({ const { shouldShowAuthBanner } = useOnboardingActions(); const shouldShowTagSourceSocialProof = shouldShowAuthBanner && isLaptop; const { user } = useContext(AuthContext); + useRecordRecentSourceVisit(source); const mostUpvotedQueryVariables = useMemo( () => ({ source: source?.id, @@ -265,6 +268,7 @@ const SourcePage = ({ dangerouslySetInnerHTML={{ __html: jsonLd }} /> + - -