From 481437a9049dd2fe24fc53963b584421f98d5f1f Mon Sep 17 00:00:00 2001 From: Amar Trebinjac Date: Wed, 13 May 2026 13:30:40 +0200 Subject: [PATCH] feat: new intro quest opener --- .../filters/IntroQuestButton.spec.tsx | 152 +++++++++---- .../components/filters/IntroQuestButton.tsx | 209 +++++++++++++----- packages/shared/src/graphql/actions.ts | 1 + 3 files changed, 262 insertions(+), 100 deletions(-) diff --git a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx index 931716ad374..369f910a884 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.spec.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.spec.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { act, render, screen } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { useAuthContext } from '../../contexts/AuthContext'; import { useSettingsContext } from '../../contexts/SettingsContext'; @@ -46,15 +47,46 @@ jest.mock('../../hooks/useQuestDashboard', () => ({ })); jest.mock('../tooltip/Tooltip', () => ({ - Tooltip: function MockTooltip({ - children, - }: { - children: React.ReactElement; - }) { + Tooltip: function MockTooltip({ children }: { children: ReactElement }) { return children; }, })); +const mockPopoverOpen = { current: false }; +const mockReactModule = () => React; + +jest.mock('@radix-ui/react-popover', () => ({ + Popover: ({ children, open }: { children: ReactNode; open?: boolean }) => { + mockPopoverOpen.current = !!open; + return mockReactModule().createElement( + 'div', + { 'data-popover-open': open ? 'true' : 'false' }, + children, + ); + }, + PopoverAnchor: ({ children }: { children: ReactNode }) => + mockReactModule().createElement(mockReactModule().Fragment, null, children), + PopoverArrow: () => null, +})); + +jest.mock('../popover/Popover', () => ({ + PopoverContent: ({ children }: { children: ReactNode }) => { + if (!mockPopoverOpen.current) { + return null; + } + return mockReactModule().createElement( + 'div', + { 'data-testid': 'intro-quest-coachmark-bubble' }, + children, + ); + }, +})); + +jest.mock('../tooltips/Portal', () => ({ + RootPortal: ({ children }: { children: ReactNode }) => + mockReactModule().createElement(mockReactModule().Fragment, null, children), +})); + const mockUseAuthContext = useAuthContext as jest.Mock; const mockUseSettingsContext = useSettingsContext as jest.Mock; const mockUseLazyModal = useLazyModal as jest.Mock; @@ -63,6 +95,7 @@ const mockUseViewSize = useViewSize as jest.Mock; const mockUseNewD1ExperienceFeature = useNewD1ExperienceFeature as jest.Mock; const mockUseQuestDashboard = useQuestDashboard as jest.Mock; const openModal = jest.fn(); +const completeAction = jest.fn(); const buildIntroQuest = (overrides: Partial = {}): UserQuest => ({ userQuestId: 'uq-1', @@ -85,9 +118,21 @@ const buildIntroQuest = (overrides: Partial = {}): UserQuest => ({ ...overrides, }); +const mockActions = ( + completed: ActionType[] = [], + { fetched = true }: { fetched?: boolean } = {}, +) => { + mockUseActions.mockReturnValue({ + checkHasCompleted: jest.fn((type: ActionType) => completed.includes(type)), + completeAction, + isActionsFetched: fetched, + }); +}; + describe('IntroQuestButton', () => { beforeEach(() => { openModal.mockReset(); + completeAction.mockReset(); mockUseAuthContext.mockReturnValue({ isAuthReady: true, isLoggedIn: true, @@ -98,9 +143,7 @@ describe('IntroQuestButton', () => { mockUseLazyModal.mockReturnValue({ openModal, }); - mockUseActions.mockReturnValue({ - checkHasCompleted: jest.fn(() => false), - }); + mockActions([]); mockUseViewSize.mockImplementation((size) => size === ViewSize.Laptop); mockUseNewD1ExperienceFeature.mockReturnValue({ value: true }); mockUseQuestDashboard.mockReturnValue({ @@ -120,52 +163,87 @@ describe('IntroQuestButton', () => { }); afterEach(() => { - jest.useRealTimers(); jest.clearAllMocks(); }); it('opens the intro quests modal with completed/total label', async () => { render(); - expect(screen.getByText('1/4')).toBeInTheDocument(); + const button = screen.getByRole('button', { + name: 'Open introduction quests (1/4), attention needed', + }); + expect(button).toHaveTextContent('1/4'); expect( - screen.getByTestId('intro-quest-attention-badge'), + within(button).getByTestId('intro-quest-attention-badge'), ).toBeInTheDocument(); - await userEvent.click( - screen.getByRole('button', { - name: 'Open introduction quests (1/4), attention needed', - }), - ); + await userEvent.click(button); expect(openModal).toHaveBeenCalledWith({ type: LazyModal.IntroQuests, }); }); - it('shows a CTA on load and retracts it after 2 seconds', () => { - jest.useFakeTimers(); + it('shows the coachmark overlay and bubble for first-time users', () => { + render(); + + expect( + screen.getByTestId('intro-quest-coachmark-overlay'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('intro-quest-coachmark-bubble'), + ).toHaveTextContent('Check out our introductory quests to get you set up!'); + }); + + it('completes intro_acknowledged when the highlighted button is clicked', async () => { + render(); + + await userEvent.click( + screen.getByRole('button', { name: /Open introduction quests/ }), + ); + + expect(completeAction).toHaveBeenCalledWith(ActionType.IntroAcknowledged); + expect(openModal).toHaveBeenCalledWith({ type: LazyModal.IntroQuests }); + }); + + it('hides the coachmark once intro has been acknowledged', () => { + mockActions([ActionType.IntroAcknowledged]); render(); - const cta = screen.getByTestId('intro-quest-cta'); + expect( + screen.queryByTestId('intro-quest-coachmark-overlay'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('intro-quest-coachmark-bubble'), + ).not.toBeInTheDocument(); + }); - expect(cta).toHaveTextContent('Get the most out of daily.dev'); - expect(cta).toHaveAttribute('data-expanded', 'true'); + it('does not show the coachmark while actions are still loading', () => { + mockActions([], { fetched: false }); - act(() => { - jest.advanceTimersByTime(2000); - }); + render(); - expect(cta).toHaveAttribute('data-expanded', 'false'); + expect( + screen.queryByTestId('intro-quest-coachmark-overlay'), + ).not.toBeInTheDocument(); + }); + + it('auto-backfills intro_acknowledged for users who already viewed intro quests', async () => { + mockActions([ActionType.ViewedIntroQuests]); + + render(); + + expect( + screen.queryByTestId('intro-quest-coachmark-overlay'), + ).not.toBeInTheDocument(); + await waitFor(() => + expect(completeAction).toHaveBeenCalledWith(ActionType.IntroAcknowledged), + ); }); it('hides the badge after intro quests have been viewed and none are claimable', () => { - mockUseActions.mockReturnValue({ - checkHasCompleted: jest.fn( - (type: ActionType) => type === ActionType.ViewedIntroQuests, - ), - }); + mockActions([ActionType.ViewedIntroQuests, ActionType.IntroAcknowledged]); render(); @@ -178,11 +256,7 @@ describe('IntroQuestButton', () => { }); it('shows the badge when a viewed intro quest becomes claimable', () => { - mockUseActions.mockReturnValue({ - checkHasCompleted: jest.fn( - (type: ActionType) => type === ActionType.ViewedIntroQuests, - ), - }); + mockActions([ActionType.ViewedIntroQuests, ActionType.IntroAcknowledged]); mockUseQuestDashboard.mockReturnValue({ data: { intro: [ @@ -267,11 +341,7 @@ describe('IntroQuestButton', () => { }); it('does not render when intro quests have been permanently hidden', () => { - mockUseActions.mockReturnValue({ - checkHasCompleted: jest.fn( - (type: ActionType) => type === ActionType.IntroQuestsCompleted, - ), - }); + mockActions([ActionType.IntroQuestsCompleted]); render(); diff --git a/packages/shared/src/components/filters/IntroQuestButton.tsx b/packages/shared/src/components/filters/IntroQuestButton.tsx index fff5da67b9f..0fd10d903f1 100644 --- a/packages/shared/src/components/filters/IntroQuestButton.tsx +++ b/packages/shared/src/components/filters/IntroQuestButton.tsx @@ -1,10 +1,14 @@ import type { ReactElement } from 'react'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import classNames from 'classnames'; +import { Popover, PopoverAnchor, PopoverArrow } from '@radix-ui/react-popover'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { TourIcon } from '../icons'; import { Tooltip } from '../tooltip/Tooltip'; import { Bubble } from '../tooltips/utils'; +import { PopoverContent } from '../popover/Popover'; +import { RootPortal } from '../tooltips/Portal'; +import { Typography, TypographyType } from '../typography/Typography'; import { useAuthContext } from '../../contexts/AuthContext'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { ActionType } from '../../graphql/actions'; @@ -15,15 +19,46 @@ import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature import { useQuestDashboard } from '../../hooks/useQuestDashboard'; import { QuestStatus } from '../../graphql/quests'; -const INTRO_QUEST_CTA = 'Get the most out of daily.dev'; -const INTRO_QUEST_CTA_DURATION_MS = 2000; +const useElementRect = ( + ref: React.RefObject, + active: boolean, +): DOMRect | null => { + const [rect, setRect] = useState(null); + + useLayoutEffect(() => { + if (!active || !ref.current || typeof window === 'undefined') { + setRect(null); + return undefined; + } + + const update = () => { + if (ref.current) { + setRect(ref.current.getBoundingClientRect()); + } + }; + + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + const observer = new ResizeObserver(update); + observer.observe(ref.current); + + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + observer.disconnect(); + }; + }, [ref, active]); + + return rect; +}; export function IntroQuestButton(): ReactElement | null { const { isAuthReady, isLoggedIn } = useAuthContext(); const { loadedSettings } = useSettingsContext(); const { openModal } = useLazyModal(); const isLaptop = useViewSize(ViewSize.Laptop); - const { checkHasCompleted } = useActions(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); const { value: isNewD1Experience } = useNewD1ExperienceFeature({ shouldEvaluate: isAuthReady && isLoggedIn && loadedSettings, }); @@ -36,8 +71,7 @@ export function IntroQuestButton(): ReactElement | null { const hasCompletedIntroQuests = checkHasCompleted( ActionType.IntroQuestsCompleted, ); - const [isIntroCtaVisible, setIsIntroCtaVisible] = useState(false); - const hasShownIntroCta = useRef(false); + const hasAcknowledgedIntro = checkHasCompleted(ActionType.IntroAcknowledged); const shouldRenderButton = isAuthReady && loadedSettings && @@ -46,23 +80,30 @@ export function IntroQuestButton(): ReactElement | null { introQuests.length > 0 && !allIntroQuestsClaimed && !hasCompletedIntroQuests; - const showIntroCta = - shouldRenderButton && (!hasShownIntroCta.current || isIntroCtaVisible); + const showCoachmark = + shouldRenderButton && + isActionsFetched && + !hasAcknowledgedIntro && + !hasViewedIntroQuests; + const anchorRef = useRef(null); + const anchorRect = useElementRect(anchorRef, showCoachmark); useEffect(() => { - if (!shouldRenderButton || hasShownIntroCta.current) { - return undefined; + if ( + shouldRenderButton && + isActionsFetched && + !hasAcknowledgedIntro && + hasViewedIntroQuests + ) { + completeAction(ActionType.IntroAcknowledged); } - - hasShownIntroCta.current = true; - setIsIntroCtaVisible(true); - const timeout = setTimeout( - () => setIsIntroCtaVisible(false), - INTRO_QUEST_CTA_DURATION_MS, - ); - - return () => clearTimeout(timeout); - }, [shouldRenderButton]); + }, [ + shouldRenderButton, + isActionsFetched, + hasAcknowledgedIntro, + hasViewedIntroQuests, + completeAction, + ]); if (!shouldRenderButton) { return null; @@ -77,47 +118,97 @@ export function IntroQuestButton(): ReactElement | null { const buttonLabel = `${completed}/${introQuests.length}`; const buttonVariant = isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary; + const handleClick = () => { + if (showCoachmark) { + completeAction(ActionType.IntroAcknowledged); + } + openModal({ type: LazyModal.IntroQuests }); + }; + + const buttonElement = ( + + ); + return ( - - - + + + event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + className="z-max max-w-[18rem] rounded-12 bg-text-primary px-4 py-3 text-surface-invert" + > + + Check out our introductory quests to get you set up! + + + + + ); } diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 1349e9272f8..41ed294acd1 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -46,6 +46,7 @@ export enum ActionType { GeneratedBrief = 'generated_brief', ViewedIntroQuests = 'viewed_intro_quests', IntroQuestsCompleted = 'intro_quests_completed', + IntroAcknowledged = 'intro_acknowledged', ClosedProfileBanner = 'closed_profile_banner', UploadedCV = 'uploaded_cv', DisableBriefCardCta = 'disable_brief_card_cta',