diff --git a/packages/extension/src/newtab/App.tsx b/packages/extension/src/newtab/App.tsx index 5376f830e7a..7f78c81db37 100644 --- a/packages/extension/src/newtab/App.tsx +++ b/packages/extension/src/newtab/App.tsx @@ -38,7 +38,6 @@ import { ExtensionContextProvider } from '../contexts/ExtensionContext'; import CustomRouter from '../lib/CustomRouter'; import { version } from '../../package.json'; import MainFeedPage from './MainFeedPage'; -import HijackingLoginStrip from './HijackingLoginStrip'; import { BootDataProvider } from '../../../shared/src/contexts/BootProvider'; import { getContentScriptPermissionAndRegister } from '../lib/extensionScripts'; import { useContentScriptStatus } from '../../../shared/src/hooks'; @@ -67,7 +66,7 @@ const feedErrorFallback: ReactElement = ( ); -function HijackingPage({ +function OnboardingHijackPage({ onPageChanged, }: { onPageChanged: (page: string) => void; @@ -87,7 +86,6 @@ function HijackingPage({ onPageChanged={onPageChanged} initialPage="/" shouldInitializeCurrentPage={false} - shortcuts={} /> ); } @@ -108,8 +106,12 @@ function InternalApp(): ReactElement { const { growthbook } = useGrowthBookContext(); const isPageReady = (growthbook?.ready && router?.isReady && isAuthReady) || isTesting; + // Logged-out users now stay on the regular MainFeedPage (with the + // sticky sign-in strip up top + the public Popular feed). Only users + // who have signed up but haven't finished onboarding still get the + // onboarding hijack so we keep nudging them through the flow. const shouldRedirectOnboarding = - isPageReady && (!user || !isOnboardingComplete) && !isTesting; + isPageReady && !!user && !isOnboardingComplete && !isTesting; useCheckLocation(); useCheckCoresRole(); @@ -150,7 +152,7 @@ function InternalApp(): ReactElement { return ( - + ); diff --git a/packages/extension/src/newtab/ExtensionSignInStrip.tsx b/packages/extension/src/newtab/ExtensionSignInStrip.tsx new file mode 100644 index 00000000000..15e01325c57 --- /dev/null +++ b/packages/extension/src/newtab/ExtensionSignInStrip.tsx @@ -0,0 +1,72 @@ +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 banner pinned to the top of the new tab for logged-out users. +// Sits above shortcuts and any other top-page modules so the auth CTAs +// are the first thing the user sees and remain reachable while +// scrolling. Shares the feed's primary background so it reads as part +// of the same surface (no glass / blur / shadow). +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..c0fed9742df --- /dev/null +++ b/packages/extension/src/newtab/ExtensionTopBanners.tsx @@ -0,0 +1,236 @@ +import type { ReactElement } from 'react'; +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import { TopHero } from '@dailydotdev/shared/src/components/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'; +import ReadingReminderCatLaptop from '@dailydotdev/shared/src/components/banners/ReadingReminderCatLaptop'; +import { + cloudinaryShortcutsIconsGmail, + cloudinaryShortcutsIconsOpenai, + cloudinaryShortcutsIconsReddit, + uploadCvBgMobile, +} from '@dailydotdev/shared/src/lib/image'; + +// Bare-illustration frame — slightly wider than tall so the CV cluster +// (rendered as a background image below) has horizontal room without +// cropping the calculator/laptop edges. `self-center` plus `items-center +// justify-center` keeps the other cards' illustrations centered too. +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 matched to `illustrationFrameClass` so the +// reminder card lines up height-wise with the CV / shortcuts cards in +// the extension top row (the `TopHero` default cat is sized for the +// taller webapp variant). +const CompactReminderCat = (): ReactElement => ( + +); + +const ShortcutsIllustration = (): ReactElement => { + const { githubShortcut } = useThemedAsset(); + // Cluster of four site icons echoes the in-feed onboarding row but + // compressed into the illustration frame so the cards line up. + 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(); + + // Once the user has at least one shortcut pinned (custom or top-site), + // the actual shortcuts row renders above these cards and the + // onboarding tile becomes redundant. Drop it so the remaining hero + // cards expand to fill the row. + 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 bypass the temporal throttling that the + // webapp homepage uses to limit how often the reminder is shown. + const reminder = useReadingReminderHero({ + requireMobile: false, + bypassThrottling: true, + }); + 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); + // Allow the user to re-pick the same file after an error. + // eslint-disable-next-line no-param-reassign + event.target.value = ''; + }} + /> +
+ {cards} +
+ + ); +}; diff --git a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx b/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx deleted file mode 100644 index 087c8a528b7..00000000000 --- a/packages/extension/src/newtab/HijackingLoginStrip.spec.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import type { AuthContextData } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { getLogContextStatic } from '@dailydotdev/shared/src/contexts/LogContext'; -import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; -import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; -import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; -import HijackingLoginStrip from './HijackingLoginStrip'; - -jest.mock('@dailydotdev/shared/src/contexts/AuthContext', () => ({ - ...jest.requireActual('@dailydotdev/shared/src/contexts/AuthContext'), - useAuthContext: jest.fn(), -})); - -const LogContext = getLogContextStatic(); -const mockUseAuthContext = useAuthContext as jest.MockedFunction< - typeof useAuthContext ->; -const logEvent = jest.fn(); -const showLogin = jest.fn(); - -const defaultAuthContext = { - user: undefined, - isLoggedIn: false, - referral: undefined, - referralOrigin: undefined, - trackingId: undefined, - shouldShowLogin: false, - showLogin, - closeLogin: jest.fn(), - loginState: undefined, - logout: jest.fn(), - updateUser: jest.fn(), - loadingUser: false, - isFetched: true, - tokenRefreshed: false, - loadedUserFromCache: false, - getRedirectUri: jest.fn(), - anonymous: undefined, - visit: undefined, - firstVisit: undefined, - deleteAccount: jest.fn(), - refetchBoot: jest.fn(), - accessToken: undefined, - squads: [], - isAuthReady: true, - geo: undefined, - isAndroidApp: false, - isGdprCovered: false, - isValidRegion: true, - isFunnel: false, -} satisfies AuthContextData; - -const renderComponent = ( - authContext: Partial = {}, -): ReturnType => { - mockUseAuthContext.mockReturnValue({ - ...defaultAuthContext, - ...authContext, - }); - - return render( - - - , - ); -}; - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('HijackingLoginStrip', () => { - it('shows a login CTA for logged out users', () => { - renderComponent(); - - expect( - screen.getByText('Unlock the full daily.dev experience'), - ).toBeVisible(); - expect( - screen.getByText('Log in to pick up where you left off.'), - ).toBeVisible(); - - const cta = screen.getByRole('button', { name: 'Log in to continue' }); - fireEvent.click(cta); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - expect(showLogin).toHaveBeenCalledWith({ - trigger: AuthTriggers.Onboarding, - options: { isLogin: true }, - }); - }); - - it('shows an onboarding CTA for logged in users who still need onboarding', () => { - renderComponent({ user: loggedUser, isLoggedIn: true }); - - expect( - screen.getByText( - 'You still have a few onboarding steps left. Finish them to unlock the full experience.', - ), - ).toBeVisible(); - - const cta = screen.getByRole('link', { name: 'Continue onboarding' }); - const expectedUrl = new URL(onboardingUrl); - expectedUrl.searchParams.append('r', 'extension'); - - expect(cta).toHaveAttribute('href', expectedUrl.toString()); - - fireEvent.click(cta); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - expect(showLogin).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/extension/src/newtab/HijackingLoginStrip.tsx b/packages/extension/src/newtab/HijackingLoginStrip.tsx deleted file mode 100644 index 7185dbc58ac..00000000000 --- a/packages/extension/src/newtab/HijackingLoginStrip.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Button, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; -import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { onboardingUrl } from '@dailydotdev/shared/src/lib/constants'; -import { LogEvent, TargetType } from '@dailydotdev/shared/src/lib/log'; -import feedStyles from '@dailydotdev/shared/src/components/Feed.module.css'; -import { cloudinaryReadingReminderCat } from '@dailydotdev/shared/src/lib/image'; - -export default function HijackingLoginStrip(): ReactElement { - const { showLogin, user } = useAuthContext(); - const { logEvent } = useLogContext(); - const isLoggedOut = !user; - const onboardingHref = (() => { - const base = new URL(onboardingUrl); - base.searchParams.append('r', 'extension'); - - return base.toString(); - })(); - - const logHijackingClick = (): void => { - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.LoginButton, - target_id: 'hijacking', - }); - }; - - return ( -
-
-
-
-
-
-
-
-
-
-

- Unlock the full daily.dev experience -

-

- {isLoggedOut - ? 'Log in to pick up where you left off.' - : 'You still have a few onboarding steps left. Finish them to unlock the full experience.'} -

- {isLoggedOut ? ( - - ) : ( - - )} -
-
-
- Sleeping cat on laptop -
-
-
-
-
- ); -} diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index 20f32e148d7..e01057cad61 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; import React, { useCallback, useContext, @@ -18,7 +18,6 @@ import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsCon import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; -import { useFeedLayout } from '@dailydotdev/shared/src/hooks'; import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext'; import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; import useCustomDefaultFeed from '@dailydotdev/shared/src/hooks/feed/useCustomDefaultFeed'; @@ -31,6 +30,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'; @@ -50,7 +51,6 @@ export type MainFeedPageProps = { onPageChanged: (page: string) => unknown; initialPage?: string; shouldInitializeCurrentPage?: boolean; - shortcuts?: ReactNode; }; const normalizePage = (page: string): string => @@ -100,7 +100,6 @@ const MainFeedPageInner = ({ onPageChanged, initialPage, shouldInitializeCurrentPage = true, - shortcuts, }: MainFeedPageProps): ReactElement => { const { logEvent } = useLogContext(); const [isSearchOn, setIsSearchOn] = useState(false); @@ -109,7 +108,6 @@ const MainFeedPageInner = ({ getInitialFeedName(initialPage), ); const [searchQuery, setSearchQuery] = useState(); - const { shouldUseListFeedLayout } = useFeedLayout({ feedRelated: false }); useCompanionSettings(); const { isActive: isDndActive, showDnd, setShowDnd } = useDndContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); @@ -176,6 +174,7 @@ const MainFeedPageInner = ({ // visual signal that customizer changes affect THEIR feed, not just // a panel-shaped overlay. const { panelWidth } = useCustomizeNewTab(); + const shortcutLinks = ; return ( <> @@ -195,6 +194,15 @@ const MainFeedPageInner = ({ onNavTabClick={onNavTabClick} screenCentered={false} customBanner={isDndActive && } + topBanner={ + <> + +
+ {shortcutLinks} +
+ + + } additionalButtons={ !loadingUser && !optOutCompanion && } @@ -224,13 +232,6 @@ const MainFeedPageInner = ({ }} /> } - shortcuts={ - shortcuts ?? ( - - ) - } /> setShowDnd(false)} /> diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx deleted file mode 100644 index f5c6243b9cc..00000000000 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; -import { - cloudinaryShortcutsIconsGmail, - cloudinaryShortcutsIconsOpenai, - cloudinaryShortcutsIconsReddit, - cloudinaryShortcutsIconsStackoverflow, -} from '@dailydotdev/shared/src/lib/image'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import type { PropsWithChildren, ReactElement } from 'react'; -import React from 'react'; -import { useThemedAsset } from '@dailydotdev/shared/src/hooks/utils'; -import { useActions } from '@dailydotdev/shared/src/hooks'; - -function ShortcutItemPlaceholder({ children }: PropsWithChildren) { - return ( -
-
- {children} -
- -
- ); -} - -interface ShortcutGetStartedProps { - onTopSitesClick: () => void; - onCustomLinksClick: () => void; -} - -export const ShortcutGetStarted = ({ - onTopSitesClick, - onCustomLinksClick, -}: ShortcutGetStartedProps): ReactElement => { - const { githubShortcut } = useThemedAsset(); - const { completeAction, checkHasCompleted } = useActions(); - - const items = [ - cloudinaryShortcutsIconsGmail, - githubShortcut, - cloudinaryShortcutsIconsReddit, - cloudinaryShortcutsIconsOpenai, - cloudinaryShortcutsIconsStackoverflow, - ]; - - const completeActionThenFire = (callback?: () => void) => { - if (!checkHasCompleted(ActionType.FirstShortcutsSession)) { - completeAction(ActionType.FirstShortcutsSession); - } - callback?.(); - }; - - return ( -
-

- Choose your most visited sites -

-
- {items.map((url) => ( - - {`Icon - - ))} - - - -
-
- - -
-
- ); -}; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index 48c3f3c4222..74adf90cc3a 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -186,23 +186,12 @@ describe('shortcut links component', () => { expect(screen.queryByText('Add shortcuts')).not.toBeInTheDocument(); }); - it('should display add shortcuts if settings is enabled and no customLinks added', async () => { - renderComponent({ - ...defaultBootData, - settings: { ...defaultSettings, customLinks: undefined }, - }); - - const addShortcuts = await screen.findByText('Add shortcuts'); - expect(addShortcuts).toBeVisible(); - - await waitFor(() => { - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.Impression, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Custom }), - }); - }); - }); + // Note: the legacy "Add shortcuts" / "Choose your most visited sites" + // onboarding card is no longer rendered inside ShortcutLinks — it now + // lives in ExtensionTopBanners alongside the reading-reminder and + // upload-CV cards. The previous tests asserting that copy from this + // component have been removed; the onboarding card is covered by the + // top-banners surface. it('should display top sites if permission is previously granted', async () => { await act(async () => { @@ -226,52 +215,9 @@ describe('shortcut links component', () => { }); }); - it('should display top sites if permission is manually granted', async () => { - renderComponent({ - ...defaultBootData, - user: { - id: 'string', - firstVisit: 'string', - referrer: 'string', - }, - settings: { ...defaultSettings, customLinks: [] }, - }); - await act(() => new Promise((resolve) => setTimeout(resolve, 100))); - - const addShortcuts = await screen.findByText('Add shortcuts'); - fireEvent.click(addShortcuts); - - await screen.findByRole('dialog'); - - const mostVisitedSites = await screen.findByText('Most visited sites'); - expect(mostVisitedSites).toBeVisible(); - fireEvent.click(mostVisitedSites); - - const title = await screen.findByText('Show most visited sites'); - expect(title).toBeVisible(); - - const inputs = await screen.findAllByRole('textbox'); - expect(inputs.length).toEqual(8); - - inputs.forEach((input) => { - expect(input).toHaveAttribute('readonly'); - }); - - const next = await screen.findByText('Add the shortcuts'); - fireEvent.click(next); - - const saveChanges = await screen.findByText('Save'); - fireEvent.click(saveChanges); - - const shortcuts = await screen.findAllByRole('link'); - expect(shortcuts.length).toEqual(3); - - expect(logEvent).toHaveBeenCalledWith({ - event_name: LogEvent.SaveShortcutAccess, - target_type: TargetType.Shortcuts, - extra: JSON.stringify({ source: ShortcutsSourceType.Browser }), - }); - }); + // Note: the previous "manually granted" flow drove the permissions + // modal via the inline "Add shortcuts" CTA. That CTA moved to + // ExtensionTopBanners, so the integration test is covered there now. it('should display custom shortcut links', async () => { renderComponent(); @@ -396,23 +342,9 @@ describe('shortcut links component', () => { }); }); - it('should show getting started for new users with no [actions and links]', async () => { - renderComponent({ - ...defaultBootData, - user: { - id: 'string', - firstVisit: 'string', - referrer: 'string', - createdAt: '2024-08-16T00:00:00.000Z', - }, - settings: { ...defaultSettings, customLinks: [] }, - }); - - const gettingStarted = await screen.findByText( - 'Choose your most visited sites', - ); - expect(gettingStarted).toBeVisible(); - }); + // Note: the "Choose your most visited sites" getting-started card for + // new users moved out of ShortcutLinks into ExtensionTopBanners; the + // visibility test is now owned by that surface. it('should hide getting started for old users with no [actions and links]', async () => { renderComponent({ diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 0bd68649ac9..5d077f2516c 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -17,7 +17,6 @@ import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/ import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; import { useIsShortcutsHubEnabled } from '@dailydotdev/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled'; import { ShortcutLinksList } from './ShortcutLinksList'; -import { ShortcutGetStarted } from './ShortcutGetStarted'; import { ShortcutLinksHub } from './ShortcutLinksHub'; import { ShortcutImportFlow } from './ShortcutImportFlow'; @@ -105,25 +104,19 @@ function LegacyShortcutLinks({ return ( <> - {!hideShortcuts && - (showGetStarted ? ( - - ) : ( - - ))} + {!hideShortcuts && !showGetStarted && ( + + )} {showPermissionsModal && } ); @@ -132,9 +125,8 @@ function LegacyShortcutLinks({ function NewShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement | null { - const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); + const { showTopSites, flags } = useSettingsContext(); const manager = useShortcutsManager(); - const { openModal } = useLazyModal(); useShortcutsMigration(); if (!showTopSites) { @@ -148,17 +140,10 @@ function NewShortcutLinks({ const showOnboarding = mode === 'manual' && manager.shortcuts.length === 0; if (showOnboarding) { - return ( - <> - - openModal({ type: LazyModal.ShortcutsManage }) - } - /> - - - ); + // Onboarding (no shortcuts yet) is now surfaced via the top hero + // banner row above the feed; render nothing here so it doesn't + // appear twice. + return ; } return ( 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 && ( { ); }); - it('should hide post', async () => { - let mutationCalled = false; + it('should hide post and replace card with the hidden feedback panel', async () => { + let hideCalled = false; renderComponent([ createFeedMock({ pageInfo: defaultFeedPage.pageInfo, @@ -822,7 +823,7 @@ describe('Feed logged in', () => { variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, }, result: () => { - mutationCalled = true; + hideCalled = true; return { data: { _: true } }; }, }, @@ -833,12 +834,90 @@ describe('Feed logged in', () => { }); const contextBtn = await screen.findByText('Hide'); contextBtn.click(); - await waitFor(() => expect(mutationCalled).toBeTruthy()); + await waitFor(() => expect(hideCalled).toBeTruthy()); + expect( + await screen.findByText('Post hidden in your feed'), + ).toBeInTheDocument(); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); + }); + + it('should restore the post when clicking Undo on the hidden feedback panel', async () => { + let unhideCalled = false; + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + { + request: { + query: UNHIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => { + unhideCalled = true; + return { data: { _: true } }; + }, + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + const undoBtn = await screen.findByRole('button', { name: 'Undo' }); + fireEvent.click(undoBtn); + + await waitFor(() => expect(unhideCalled).toBeTruthy()); await waitFor(() => expect( - screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + screen.queryByText('Post hidden in your feed'), ).not.toBeInTheDocument(), ); + expect( + await screen.findByTitle( + 'Eminem Quotes Generator - Simple PHP RESTful API', + ), + ).toBeInTheDocument(); + }); + + it('should remove the post from the feed when clicking Done on the hidden feedback panel', async () => { + renderComponent([ + createFeedMock({ + pageInfo: defaultFeedPage.pageInfo, + edges: [defaultFeedPage.edges[0]], + }), + { + request: { + query: HIDE_POST_MUTATION, + variables: { id: '4f354bb73009e4adfa5dbcbf9b3c4ebf' }, + }, + result: () => ({ data: { _: true } }), + }, + ]); + + const [menuBtn] = await screen.findAllByLabelText('Options'); + fireEvent.keyDown(menuBtn, { key: ' ' }); + (await screen.findByText('Hide')).click(); + + const doneBtn = await screen.findByRole('button', { name: 'Done' }); + fireEvent.click(doneBtn); + + await waitFor(() => + expect( + screen.queryByText('Post hidden in your feed'), + ).not.toBeInTheDocument(), + ); + expect( + screen.queryByTitle('Eminem Quotes Generator - Simple PHP RESTful API'), + ).not.toBeInTheDocument(); }); it('should block a source', async () => { diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index f8b8602753c..7a8a9f7ad1d 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -28,12 +28,7 @@ import { useLogContext } from '../contexts/LogContext'; import { feedLogExtra, postLogEvent } from '../lib/feed'; import { usePostModalNavigation } from '../hooks/usePostModalNavigation'; import { useSharePost } from '../hooks/useSharePost'; -import { - LogEvent, - NotificationCtaPlacement, - Origin, - TargetId, -} from '../lib/log'; +import { LogEvent, NotificationCtaPlacement, Origin } from '../lib/log'; import { SharedFeedPage } from './utilities'; import type { FeedContainerProps } from './feeds/FeedContainer'; import { FeedContainer } from './feeds/FeedContainer'; @@ -64,7 +59,6 @@ import { useFeedBookmarkPost } from '../hooks/bookmark/useFeedBookmarkPost'; import usePlusEntry from '../hooks/usePlusEntry'; import { FeedCardContext } from '../features/posts/FeedCardContext'; import { - briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, featureReaderModal, @@ -105,7 +99,6 @@ export interface FeedProps 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..f5d822edf8b 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( () => @@ -66,6 +72,13 @@ export interface MainLayoutProps canGoBack?: string; hideBackButton?: boolean; hideFeedbackWidget?: boolean; + /** + * Slot rendered above the floating feed card, next to the existing + * reading-reminder TopHero. Used by the extension new tab to render + * its row of onboarding hero cards in the same outer chrome where + * the reminder card lives, so the card sits OUTSIDE the feed frame. + */ + topBanner?: ReactNode; } export const feeds = Object.values(SharedFeedPage); @@ -76,33 +89,53 @@ function MainLayoutComponent({ isNavItemsButton, customBanner, additionalButtons, - screenCentered = true, showSidebar = true, className, onLogoClick, 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(); 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). + // On the extension we keep the floating card layout even for logged-out + // users so the new tab doesn't snap back to a header-on-top layout the + // moment the user logs out — login/signup are surfaced via the top + // hero card row instead. + const sidebarOwnsHeader = + (isLoggedIn || isExtension) && 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 +210,8 @@ function MainLayoutComponent({ return null; } - const isScreenCentered = - isLaptopXL && screenCenteredOnMobileLayout ? true : screenCentered; - return ( -
+
{canGoBack && } {customBanner} {isBannerAvailable && } @@ -203,33 +233,69 @@ function MainLayoutComponent({
{isAuthReady && showSidebar && ( )} - {children} +
+ {shouldShowTopHero && ( + + onEnableReadingReminder(NotificationCtaPlacement.TopHero) + } + onClose={() => + onDismissReadingReminder(NotificationCtaPlacement.TopHero) + } + /> + )} + {topBanner} +
+ {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..52f97b4f62e 100644 --- a/packages/shared/src/components/banners/HeroBottomBanner.tsx +++ b/packages/shared/src/components/banners/HeroBottomBanner.tsx @@ -1,65 +1,114 @@ 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'; -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 = { className?: string; + /** + * Small grey eyebrow above the bold headline. Omit for a single-line + * card that only shows the headline (`subtitle`). The original + * "Enable reminder" hero falls back to its legacy copy so existing + * webapp callers keep their two-line layout. + */ title?: string; subtitle?: string; - onCtaClick: () => void; - onClose: () => void; + onCtaClick?: () => void; + /** + * When omitted, the dismiss/close button is not rendered (use for + * cards that the user must not be able to hide, e.g. the logged-out + * sign-in card). + */ + onClose?: () => void; + /** + * Optional illustration rendered on the left side of the card. + * Defaults to the reading-reminder cat artwork to keep the original + * "Enable reminder" hero usage backwards-compatible. + */ + illustration?: ReactNode; + ctaLabel?: string; + ctaVariant?: ButtonVariant; + /** + * Optional custom action node rendered in place of the default + * single-CTA button. Use when the card needs multiple buttons + * (e.g. Log in + Sign up). + */ + actions?: ReactNode; }; +const defaultIllustration = ( + +); + export const TopHero = ({ className, - title = 'Never miss a learning day', + title, subtitle = 'Turn on your daily reading reminder and keep your routine.', onCtaClick, onClose, + illustration = defaultIllustration, + ctaLabel = 'Enable reminder', + ctaVariant = ButtonVariant.Primary, + actions, }: TopHeroProps): ReactElement => { return (
-
-
-
-
-
-
+ {illustration} +
+
+ {!!title && ( + + {title} + + )} + + {subtitle} + +
+ {actions ?? ( -
-
-
- -
-
-
+ variant={ctaVariant} + size={ButtonSize.Small} + onClick={onCtaClick} + > + {ctaLabel} + + )}
+ {onClose && ( + + )}
); }; diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 7fa5060f681..7b8379dc088 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -16,18 +16,30 @@ 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'; +import { AuthTriggers } from '../../lib/auth'; import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; 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(); @@ -36,9 +48,14 @@ export const ToggleClickbaitShield = ({ const { flags, updateFlag } = useSettingsContext(); const [loading, setLoading] = useState(false); const router = useRouter(); - const { user } = useAuthContext(); + const { user, showLogin } = useAuthContext(); const { maxTries, hasUsedFreeTrial, triesLeft } = useClickbaitTries(); const isClickbaitShieldEnabled = flags?.clickbaitShieldEnabled ?? false; + const triggerLogin = () => + showLogin({ + trigger: AuthTriggers.MainButton, + options: { isLogin: false }, + }); const commonIconProps: ButtonProps<'button'> = { size: ButtonSize.Medium, @@ -62,13 +79,17 @@ export const ToggleClickbaitShield = ({ {...commonIconProps} icon={ hasUsedFreeTrial ? ( - + ) : ( - + ) } onClick={() => { if (!user) { + triggerLogin(); return; } router.push( @@ -91,15 +112,20 @@ export const ToggleClickbaitShield = ({ > + + {isSidebar && ( + + )} +
+ ); + } + return ( + {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..9f630683c5d 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -17,6 +17,8 @@ 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'; +import { isExtension } from '../../lib/func'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -35,12 +37,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 +60,16 @@ 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. + // On the extension we also hide it for logged-out users — the new tab + // surfaces login/signup via the top hero card row, so we don't want + // the page to snap back to a separate header bar after sign-out. + const shouldHideForSidebar = + isLaptop && !!sidebarRendered && (isLoggedIn || isExtension); + const customizerWidth = panelWidth ? `${panelWidth}px` : '0px'; useEffect(() => { setHasHydrated(true); @@ -87,6 +95,10 @@ function MainLayoutHeader({ ); }, [shouldUseLoadedSettings, isSearchPage, hasBanner]); + if (shouldHideForSidebar) { + return null; + } + if (shouldRenderFeedNav) { return ( <> @@ -111,9 +123,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..497daa19467 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -8,6 +8,7 @@ import React, { useContext } from 'react'; import classed from '../../lib/classed'; import { SharedFeedPage } from '../utilities'; import MyFeedHeading from '../filters/MyFeedHeading'; +import { AchievementTrackerButton } from '../filters/AchievementTrackerButton'; import type { DropdownProps } from '../fields/Dropdown'; import { Dropdown } from '../fields/Dropdown'; import { Button } from '../buttons/Button'; @@ -33,8 +34,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, @@ -84,35 +85,48 @@ export const SearchControlHeader = ({ const { user } = useAuthContext(); const { logEvent } = useLogContext(); const { sortingEnabled } = useContext(SettingsContext); - const { isUpvoted, isSortableFeed } = useFeedName({ feedName }); + const { isUpvoted, isPopular, isSortableFeed } = useFeedName({ feedName }); const isLaptop = useViewSize(ViewSize.Laptop); const isMobile = useViewSize(ViewSize.MobileL); const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const browserName = getCurrentBrowserName(); const isEdge = browserName === BrowserName.Edge; + const isExtension = checkIsExtension(); const feedsWithActions = [ SharedFeedPage.MyFeed, SharedFeedPage.Custom, SharedFeedPage.CustomForm, ]; - const hasFeedActions = feedsWithActions.includes(feedName as SharedFeedPage); + // The extension's logged-out new tab still needs the Feed + // settings / Brief / Clickbait Shield strip so the page looks + // identical to the logged-in version. Each button intercepts + // !user clicks below to open the auth modal instead of running + // its normal action, so surfacing them here is safe. + const hasFeedActions = + feedsWithActions.includes(feedName as SharedFeedPage) || + (isExtension && !user && isPopular); if (isMobile) { 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 +145,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 +169,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/modals/common/Modal.tsx b/packages/shared/src/components/modals/common/Modal.tsx index 5a50ad776ae..fd7e100666a 100644 --- a/packages/shared/src/components/modals/common/Modal.tsx +++ b/packages/shared/src/components/modals/common/Modal.tsx @@ -16,7 +16,7 @@ import { import classed from '../../../lib/classed'; import { ModalStepsWrapper } from './ModalStepsWrapper'; import type { LogEvent } from '../../../lib/log'; -import { useViewSize, ViewSize } from '../../../hooks'; +import { useViewSize, ViewSize } from '../../../hooks/useViewSize'; import type { DrawerOnMobileProps } from '../../drawers'; import { Drawer } from '../../drawers'; import type { FormWrapperProps } from '../../fields/form'; diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 1122dc10e27..9afc5d799ec 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -13,8 +13,15 @@ import { webappUrl } from '../../lib/constants'; import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; +import { IconSize } from '../Icon'; -function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { +function NotificationsBell({ + compact, + rail, +}: { + compact?: boolean; + rail?: boolean; +}): ReactElement { const router = useRouter(); const atNotificationsPage = router.pathname === notificationsUrl; const { logEvent } = useLogContext(); @@ -31,6 +38,39 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement { const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option; + if (rail) { + return ( + + + + ); + } + return (
diff --git a/packages/shared/src/components/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/post/block/PostHiddenPanel.tsx b/packages/shared/src/components/post/block/PostHiddenPanel.tsx new file mode 100644 index 00000000000..da7a34cae00 --- /dev/null +++ b/packages/shared/src/components/post/block/PostHiddenPanel.tsx @@ -0,0 +1,175 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import type { Post } from '../../../graphql/posts'; +import { useHidePost } from '../../../hooks/post/useHidePost'; +import { useBlockPostPanel } from '../../../hooks/post/useBlockPostPanel'; +import useTagAndSource from '../../../hooks/useTagAndSource'; +import useFeedSettings from '../../../hooks/useFeedSettings'; +import { useCustomFeed } from '../../../hooks/feed/useCustomFeed'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../modals/common/types'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../../buttons/Button'; +import CloseButton from '../../CloseButton'; +import { SourceAvatar } from '../../profile/source'; +import { GenericTagButton } from '../../filters/TagButton'; +import { Origin } from '../../../lib/log'; +import type { BlockTagSelection } from './common'; + +interface PostHiddenPanelProps { + post: Post; + className?: string; +} + +export function PostHiddenPanel({ + post, + className, +}: PostHiddenPanelProps): ReactElement { + const { feedId: customFeedId } = useCustomFeed(); + const { feedSettings } = useFeedSettings({ feedId: customFeedId }); + const { source } = post; + if (!source) { + throw new Error('PostHiddenPanel requires post.source'); + } + const isSourceAlreadyBlocked = + feedSettings?.excludeSources?.some(({ id }) => id === source.id) ?? false; + + const [shouldBlockSource, setShouldBlockSource] = useState( + isSourceAlreadyBlocked, + ); + const [tags, setTags] = useState(() => + (post.tags ?? []).reduce( + (acc, tag) => ({ ...acc, [tag]: false }), + {}, + ), + ); + + const { onUnhide, onConfirmDismiss } = useHidePost({ post }); + const { onClose } = useBlockPostPanel(post); + const { onBlockTags, onBlockSource } = useTagAndSource({ + origin: Origin.PostContextMenu, + postId: post.id, + shouldInvalidateQueries: false, + feedId: customFeedId, + }); + const { openModal } = useLazyModal(); + + const selectedTags = Object.entries(tags) + .filter(([, selected]) => selected) + .map(([tag]) => tag); + const willBlockSource = shouldBlockSource && !isSourceAlreadyBlocked; + + const handleDone = async () => { + if (selectedTags.length > 0) { + await onBlockTags({ tags: selectedTags, requireLogin: true }); + } + + if (willBlockSource) { + await onBlockSource({ source, requireLogin: true }); + } + + if (willBlockSource) { + onConfirmDismiss('unfollow'); + return; + } + + if (selectedTags.length > 0) { + onConfirmDismiss('block'); + return; + } + + onConfirmDismiss('done'); + }; + + const handleReport = () => { + onClose(true); + openModal({ + type: LazyModal.ReportPost, + props: { + post, + origin: Origin.PostContextMenu, + onReported: () => { + onConfirmDismiss('report'); + }, + }, + }); + }; + + return ( +
+ onConfirmDismiss('done')} + size={ButtonSize.Small} + /> +

Post hidden in your feed

+

+ Help us improve. Tell us what didn't work for you (optional). +

+ + {!isSourceAlreadyBlocked && ( + + )} + {(post.tags ?? []).map((tag) => ( + setTags({ ...tags, [tag]: !tags[tag] })} + tagItem={tag} + data-testid="hideBlockTagButton" + /> + ))} + + + + + + +
+ ); +} 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 (
+
+ {children} +
+ + ); }; diff --git a/packages/shared/src/components/streak/ReadingStreakButton.tsx b/packages/shared/src/components/streak/ReadingStreakButton.tsx index a957620833b..bc51cbd7ae2 100644 --- a/packages/shared/src/components/streak/ReadingStreakButton.tsx +++ b/packages/shared/src/components/streak/ReadingStreakButton.tsx @@ -2,8 +2,12 @@ import type { ReactElement } from 'react'; import React, { useCallback, useState } from 'react'; import classnames from 'classnames'; import { ReadingStreakPopup } from './popup/ReadingStreakPopup'; -import type { ButtonIconPosition } from '../buttons/Button'; -import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { + Button, + ButtonIconPosition, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; import { ReadingStreakIcon, WarningIcon } from '../icons'; import { SimpleTooltip } from '../tooltips'; import type { UserStreak } from '../../graphql/users'; @@ -17,6 +21,7 @@ import ConditionalWrapper from '../ConditionalWrapper'; import type { TooltipPosition } from '../tooltips/BaseTooltipContainer'; import { useAuthContext } from '../../contexts/AuthContext'; import { isSameDayInTimezone } from '../../lib/timezones'; +import type { IconSize } from '../Icon'; import { IconWrapper } from '../Icon'; import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk'; @@ -25,6 +30,8 @@ interface ReadingStreakButtonProps { isLoading: boolean; compact?: boolean; iconPosition?: ButtonIconPosition; + iconSize?: IconSize; + appendTooltipToBody?: boolean; className?: string; } @@ -34,6 +41,7 @@ interface CustomStreaksTooltipProps { shouldShowStreaks?: boolean; setShouldShowStreaks?: (value: boolean) => void; placement: TooltipPosition; + appendTooltipToBody?: boolean; } function CustomStreaksTooltip({ @@ -42,6 +50,7 @@ function CustomStreaksTooltip({ shouldShowStreaks, setShouldShowStreaks, placement, + appendTooltipToBody, }: CustomStreaksTooltipProps): ReactElement { return ( document.body : undefined} + zIndex={1000} container={{ paddingClassName: 'p-0', bgClassName: 'bg-accent-pepper-subtlest', @@ -57,7 +68,7 @@ function CustomStreaksTooltip({ className: 'border border-border-subtlest-tertiary rounded-16', }} content={} - onClickOutside={() => setShouldShowStreaks(false)} + onClickOutside={() => setShouldShowStreaks?.(false)} > {children} @@ -68,9 +79,11 @@ export function ReadingStreakButton({ streak, isLoading, compact, - iconPosition, + iconPosition = ButtonIconPosition.Left, + iconSize, + appendTooltipToBody, className, -}: ReadingStreakButtonProps): ReactElement { +}: ReadingStreakButtonProps): ReactElement | null { const { logEvent } = useLogContext(); const { user } = useAuthContext(); const isLaptop = useViewSize(ViewSize.Laptop); @@ -78,6 +91,7 @@ export function ReadingStreakButton({ const [shouldShowStreaks, setShouldShowStreaks] = useState(false); const hasReadToday = streak?.lastViewAt && + user && isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); const isTimezoneOk = useStreakTimezoneOk(); @@ -105,15 +119,16 @@ export function ReadingStreakButton({ <> ( + wrapper={(children) => ( - {children} + {children as ReactElement} )} > @@ -122,7 +137,10 @@ export function ReadingStreakButton({ type="button" iconPosition={iconPosition} icon={ - + {!isTimezoneOk && ( @@ -134,7 +152,7 @@ export function ReadingStreakButton({ } onClick={handleToggle} className={classnames( - 'gap-1', + 'gap-0.5', compact && 'text-accent-bacon-default', className, )} diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index b0a1cdb2b79..1e04e09d031 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -23,6 +23,8 @@ export enum InteractivePopupPosition { LeftCenter = 'leftCenter', LeftEnd = 'leftEnd', ProfileMenu = 'profileMenu', + SidebarProfileMenu = 'sidebarProfileMenu', + SidebarSupportMenu = 'sidebarSupportMenu', Screen = 'screen', } @@ -61,6 +63,8 @@ const positionClass: Record = { leftCenter: classNames(leftClass, centerClassY), leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), + sidebarProfileMenu: 'left-[4.75rem] top-12', + sidebarSupportMenu: 'bottom-3 left-[4.75rem]', screen: 'inset-0 w-screen h-screen', }; @@ -142,6 +146,8 @@ function InteractivePopup({ {...props} > {finalPosition !== InteractivePopupPosition.ProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarProfileMenu && + finalPosition !== InteractivePopupPosition.SidebarSupportMenu && onClose && ( diff --git a/packages/webapp/components/layouts/SettingsLayout/index.tsx b/packages/webapp/components/layouts/SettingsLayout/index.tsx index 406a21aea48..f5cc4def22e 100644 --- a/packages/webapp/components/layouts/SettingsLayout/index.tsx +++ b/packages/webapp/components/layouts/SettingsLayout/index.tsx @@ -24,15 +24,28 @@ import { ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; import { BuyCreditsButton } from '@dailydotdev/shared/src/components/credit/BuyCreditsButton'; import { useCanPurchaseCores } from '@dailydotdev/shared/src/hooks/useCoresFeature'; import { getPathnameWithQuery } from '@dailydotdev/shared/src/lib'; import { Origin } from '@dailydotdev/shared/src/lib/log'; +import { PageHeader } from '@dailydotdev/shared/src/components/layout/PageHeader'; import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout'; import { getLayout as getMainLayout } from '../MainLayout'; +// Stable DOM ids for the per-page title / actions portal targets that +// `AccountPageContainer` writes into via `createPortal`. Using fixed +// ids (queried with `document.getElementById` from the consumer) avoids +// having to expose live DOM nodes via React state — the previous +// "ref={setState}" approach fired a state update during ref detach in +// the commit phase, which on settings pages cascaded into React's +// "Maximum update depth exceeded" guard. +export const SETTINGS_HEADER_TITLE_PORTAL_ID = 'settings-header-title-portal'; +export const SETTINGS_HEADER_ACTIONS_PORTAL_ID = + 'settings-header-actions-portal'; + const ProfileSettingsMenuMobile = dynamic( () => import( @@ -56,12 +69,19 @@ const ProfileSettingsMenuDesktop = dynamic( export const navigationKey = generateQueryKey( RequestKey.AccountNavigation, - null, + undefined, ); +// Mobile tray + dedicated sidebar still label this whole area as +// "Settings". The top page-header strip on the floating card, +// however, mirrors the *current page* (Profile, Account & Security, +// Job preferences, ...) — the dedicated sidebar already establishes +// that you're inside Settings, so repeating it in the page header +// just pushes the actually-useful page title out of the visible area. + export default function SettingsLayout({ children, -}: PropsWithChildren): ReactElement { +}: PropsWithChildren): ReactElement | null { const router = useRouter(); const { user: profile, isAuthReady } = useContext(AuthContext); const isMobile = useViewSize(ViewSize.MobileL); @@ -84,11 +104,30 @@ export default function SettingsLayout({ const { formRef } = useAuthForms(); + const renderSettingsMenu = (): ReactElement | null => { + if (isMobile) { + return ( + router.push(profile?.permalink ?? webappUrl)} + /> + ); + } + if (isLaptop) { + return null; + } + return ; + }; + if (!isAuthReady) { return null; } if (!profile) { + if (!formRef) { + return null; + } return (
+ ); + return ( <> {!isMobile && !isLaptop && ( @@ -134,13 +184,21 @@ export default function SettingsLayout({ />
)} + {isLaptop && ( + + + + )} {router.query.redirectTo && router.query.redirectCopy && ( + )} + + )} + - )} - + + + + + ); + })} + +); + 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 }} /> + - -