diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index e26c70c3aa6..bb5b8ad2377 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -67,8 +67,8 @@ import { briefCardFeedFeature, briefFeedEntrypointPage, featureFeedAdTemplate, - featureNewD1Experience, } from '../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../hooks/useNewD1ExperienceFeature'; import type { AwardProps } from '../graphql/njord'; import { getProductsQueryOptions } from '../graphql/njord'; import { useUpdateQuery } from '../hooks/useUpdateQuery'; @@ -263,8 +263,7 @@ export default function Feed({ feature: briefCardFeedFeature, shouldEvaluate: shouldEvaluateBriefCard, }); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateBriefCard, }); const showBriefCard = diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx index 210c160e973..5a238c5a371 100644 --- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx +++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx @@ -21,8 +21,7 @@ import { useAuthContext } from '../../contexts/AuthContext'; import { webappUrl } from '../../lib/constants'; import { FeedSettingsMenu } from '../feeds/FeedSettings/types'; import { Tooltip } from '../tooltip/Tooltip'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; export const ToggleClickbaitShield = ({ origin, @@ -41,8 +40,7 @@ export const ToggleClickbaitShield = ({ const { user } = useAuthContext(); const { maxTries, hasUsedFreeTrial, triesLeft } = useClickbaitTries(); const isClickbaitShieldEnabled = flags?.clickbaitShieldEnabled ?? false; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: !isPlus, }); diff --git a/packages/shared/src/components/cards/common/ClickbaitShield.tsx b/packages/shared/src/components/cards/common/ClickbaitShield.tsx index de7333fadf3..fb3c8273354 100644 --- a/packages/shared/src/components/cards/common/ClickbaitShield.tsx +++ b/packages/shared/src/components/cards/common/ClickbaitShield.tsx @@ -17,8 +17,7 @@ import { FeedSettingsMenu } from '../../feeds/FeedSettings/types'; import { useAuthContext } from '../../../contexts/AuthContext'; import { webappUrl } from '../../../lib/constants'; import { Tooltip } from '../../tooltip/Tooltip'; -import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../../hooks/useNewD1ExperienceFeature'; export const ClickbaitShield = ({ post, @@ -33,8 +32,7 @@ export const ClickbaitShield = ({ const router = useRouter(); const { user } = useAuthContext(); const { hasUsedFreeTrial, triesLeft } = useClickbaitTries(); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: !isPlus, }); diff --git a/packages/shared/src/components/feeds/FeedContainer.tsx b/packages/shared/src/components/feeds/FeedContainer.tsx index ea757248fc7..dbcac7df244 100644 --- a/packages/shared/src/components/feeds/FeedContainer.tsx +++ b/packages/shared/src/components/feeds/FeedContainer.tsx @@ -30,8 +30,7 @@ import { } from '../../lib/image'; import { useUploadCv } from '../../features/profile/hooks/useUploadCv'; import { TargetId } from '../../lib/log'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; export interface FeedContainerProps { children: ReactNode; @@ -197,8 +196,7 @@ export const FeedContainer = ({ }); const shouldEvaluateBanner = !!marketingCta && shouldShow && activeFeedName === SharedFeedPage.MyFeed; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateBanner, }); const shouldShowBanner = shouldEvaluateBanner && !isNewD1Experience; diff --git a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx index c69ce2c8332..931716ad374 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx @@ -6,7 +6,7 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; import { useLazyModal } from '../../hooks/useLazyModal'; import { ActionType } from '../../graphql/actions'; import { useActions, useViewSize, ViewSize } from '../../hooks'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; import { useQuestDashboard } from '../../hooks/useQuestDashboard'; import { IntroQuestButton } from './IntroQuestButton'; import { LazyModal } from '../modals/common/types'; @@ -37,8 +37,8 @@ jest.mock('../../hooks', () => ({ useViewSize: jest.fn(), })); -jest.mock('../../hooks/useConditionalFeature', () => ({ - useConditionalFeature: jest.fn(), +jest.mock('../../hooks/useNewD1ExperienceFeature', () => ({ + useNewD1ExperienceFeature: jest.fn(), })); jest.mock('../../hooks/useQuestDashboard', () => ({ @@ -60,7 +60,7 @@ const mockUseSettingsContext = useSettingsContext as jest.Mock; const mockUseLazyModal = useLazyModal as jest.Mock; const mockUseActions = useActions as jest.Mock; const mockUseViewSize = useViewSize as jest.Mock; -const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseNewD1ExperienceFeature = useNewD1ExperienceFeature as jest.Mock; const mockUseQuestDashboard = useQuestDashboard as jest.Mock; const openModal = jest.fn(); @@ -102,7 +102,7 @@ describe('IntroQuestButton', () => { checkHasCompleted: jest.fn(() => false), }); mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); - mockUseConditionalFeature.mockReturnValue({ value: true }); + mockUseNewD1ExperienceFeature.mockReturnValue({ value: true }); mockUseQuestDashboard.mockReturnValue({ data: { intro: [ @@ -218,7 +218,7 @@ describe('IntroQuestButton', () => { }); it('does not render when new D1 experience flag is off', () => { - mockUseConditionalFeature.mockReturnValue({ value: false }); + mockUseNewD1ExperienceFeature.mockReturnValue({ value: false }); render(); diff --git a/packages/shared/src/components/filters/IntroQuestButton.tsx b/packages/shared/src/components/filters/IntroQuestButton.tsx index 58dd3dde7ee..fff5da67b9f 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.tsx @@ -11,8 +11,7 @@ import { ActionType } from '../../graphql/actions'; import { useActions, useViewSize, ViewSize } from '../../hooks'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; import { useQuestDashboard } from '../../hooks/useQuestDashboard'; import { QuestStatus } from '../../graphql/quests'; @@ -25,8 +24,7 @@ export function IntroQuestButton(): ReactElement | null { const { openModal } = useLazyModal(); const isLaptop = useViewSize(ViewSize.Laptop); const { checkHasCompleted } = useActions(); - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: isAuthReady && isLoggedIn && loadedSettings, }); const { data } = useQuestDashboard(); diff --git a/packages/shared/src/components/layout/common.spec.tsx b/packages/shared/src/components/layout/common.spec.tsx index 76056585983..d78bd26ab33 100644 --- a/packages/shared/src/components/layout/common.spec.tsx +++ b/packages/shared/src/components/layout/common.spec.tsx @@ -100,6 +100,10 @@ jest.mock('../tooltip/Tooltip', () => ({ }, })); +jest.mock('../../hooks/useNewD1ExperienceFeature', () => ({ + useNewD1ExperienceFeature: jest.fn().mockReturnValue({ value: false }), +})); + const mockUseAuthContext = useAuthContext as jest.Mock; const mockUseLogContext = useLogContext as jest.Mock; const mockUseActions = useActions as jest.Mock; diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx index 65111d10e5a..ae07d26ebbb 100644 --- a/packages/shared/src/components/layout/common.tsx +++ b/packages/shared/src/components/layout/common.tsx @@ -48,8 +48,7 @@ import { import { downloadBrowserExtension } from '../../lib/constants'; import { anchorDefaultRel } from '../../lib/strings'; import ConditionalWrapper from '../ConditionalWrapper'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature'; type State = [T, Dispatch>]; @@ -114,8 +113,7 @@ export const SearchControlHeader = ({ isActionsFetched && canInstallExtension && !hasDismissedInstallExtension; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: shouldEvaluateInstallExtensionPrompt, }); diff --git a/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx b/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx new file mode 100644 index 00000000000..759ea8faa74 --- /dev/null +++ b/packages/shared/src/hooks/__tests__/useNewD1ExperienceFeature.spec.tsx @@ -0,0 +1,150 @@ +import { renderHook } from '@testing-library/react'; +import { useNewD1ExperienceFeature } from '../useNewD1ExperienceFeature'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { useQuestDashboard } from '../useQuestDashboard'; +import { + QuestRewardType, + QuestStatus, + QuestType, + type UserQuest, +} from '../../graphql/quests'; +import { featureNewD1Experience } from '../../lib/featureManagement'; + +jest.mock('../useConditionalFeature', () => ({ + useConditionalFeature: jest.fn(), +})); + +jest.mock('../useQuestDashboard', () => ({ + useQuestDashboard: jest.fn(), +})); + +const mockUseConditionalFeature = useConditionalFeature as jest.Mock; +const mockUseQuestDashboard = useQuestDashboard as jest.Mock; + +const buildIntroQuest = (overrides: Partial = {}): UserQuest => ({ + userQuestId: 'uq-1', + rotationId: 'rot-1', + progress: 0, + status: QuestStatus.InProgress, + completedAt: null, + claimedAt: null, + locked: false, + claimable: false, + quest: { + id: 'quest-1', + name: 'Install the browser extension', + description: 'Pin daily.dev.', + type: QuestType.Intro, + eventType: 'extension_install', + targetCount: 1, + }, + rewards: [{ type: QuestRewardType.Xp, amount: 10 }], + ...overrides, +}); + +describe('useNewD1ExperienceFeature', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseConditionalFeature.mockReturnValue({ + value: true, + isLoading: false, + }); + }); + + it('returns false when intro quests are empty even if GrowthBook resolves true', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: false, + }); + }); + + it('returns false while quest dashboard data is unavailable', () => { + mockUseQuestDashboard.mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + expect(result.current.isLoading).toBe(true); + }); + + it('delegates to GrowthBook value when intro quests exist', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(true); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: true, + }); + }); + + it('returns false when GrowthBook value is false even with intro quests', () => { + mockUseConditionalFeature.mockReturnValue({ + value: false, + isLoading: false, + }); + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: true }), + ); + + expect(result.current.value).toBe(false); + }); + + it('returns false without evaluating when shouldEvaluate is false', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => + useNewD1ExperienceFeature({ shouldEvaluate: false }), + ); + + expect(result.current.value).toBe(false); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: false, + }); + }); + + it('treats missing shouldEvaluate as true', () => { + mockUseQuestDashboard.mockReturnValue({ + data: { intro: [buildIntroQuest()] }, + isLoading: false, + }); + + const { result } = renderHook(() => useNewD1ExperienceFeature()); + + expect(result.current.value).toBe(true); + expect(mockUseConditionalFeature).toHaveBeenCalledWith({ + feature: featureNewD1Experience, + shouldEvaluate: true, + }); + }); +}); diff --git a/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts b/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts index 8e31af5c437..92b321bdbde 100644 --- a/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts +++ b/packages/shared/src/hooks/profile/useProfileCompletionIndicator.ts @@ -2,8 +2,7 @@ import { useCallback } from 'react'; import { useAuthContext } from '../../contexts/AuthContext'; import { useActions } from '../useActions'; import { ActionType } from '../../graphql/actions'; -import { useConditionalFeature } from '../useConditionalFeature'; -import { featureNewD1Experience } from '../../lib/featureManagement'; +import { useNewD1ExperienceFeature } from '../useNewD1ExperienceFeature'; interface UseProfileCompletionIndicator { showIndicator: boolean; @@ -29,8 +28,7 @@ export const useProfileCompletionIndicator = const shouldEvaluate = isActionsFetched && !isDismissed && profileCompletionPercentage < 100; - const { value: isNewD1Experience } = useConditionalFeature({ - feature: featureNewD1Experience, + const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate, }); diff --git a/packages/shared/src/hooks/useNewD1ExperienceFeature.ts b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts new file mode 100644 index 00000000000..53d09837c73 --- /dev/null +++ b/packages/shared/src/hooks/useNewD1ExperienceFeature.ts @@ -0,0 +1,29 @@ +import { featureNewD1Experience } from '../lib/featureManagement'; +import { useConditionalFeature } from './useConditionalFeature'; +import { useQuestDashboard } from './useQuestDashboard'; + +interface UseNewD1ExperienceFeature { + value: boolean; + isLoading: boolean; +} + +export const useNewD1ExperienceFeature = ({ + shouldEvaluate = true, +}: { + shouldEvaluate?: boolean; +} = {}): UseNewD1ExperienceFeature => { + const { data, isLoading: isQuestsLoading } = useQuestDashboard({ + enabled: shouldEvaluate, + }); + const hasIntroQuests = (data?.intro?.length ?? 0) > 0; + const enabled = shouldEvaluate && hasIntroQuests; + const { value, isLoading } = useConditionalFeature({ + feature: featureNewD1Experience, + shouldEvaluate: enabled, + }); + + return { + value: enabled && value, + isLoading: isLoading || isQuestsLoading, + }; +}; diff --git a/packages/shared/src/hooks/useQuestDashboard.ts b/packages/shared/src/hooks/useQuestDashboard.ts index b18b8c008ff..f44fec33797 100644 --- a/packages/shared/src/hooks/useQuestDashboard.ts +++ b/packages/shared/src/hooks/useQuestDashboard.ts @@ -5,7 +5,9 @@ import { QUEST_DASHBOARD_QUERY } from '../graphql/quests'; import { RequestKey, StaleTime, generateQueryKey } from '../lib/query'; import { useRequestProtocol } from './useRequestProtocol'; -export const useQuestDashboard = () => { +export const useQuestDashboard = ({ + enabled = true, +}: { enabled?: boolean } = {}) => { const { isLoggedIn, user } = useAuthContext(); const { requestMethod } = useRequestProtocol(); @@ -18,7 +20,7 @@ export const useQuestDashboard = () => { return result.questDashboard; }, - enabled: isLoggedIn, + enabled: isLoggedIn && enabled, staleTime: StaleTime.OneMinute, retry: false, });