From ca7c3cfcc593d4b89a69ebccc565cae60ecbedba Mon Sep 17 00:00:00 2001 From: davidercruz Date: Wed, 29 Apr 2026 17:42:35 +0100 Subject: [PATCH 1/6] feat(shared): onboarding personas quick-start with tag pop animation Adds an A/B-gated "persona" picker (Frontend, Backend, AI/ML, etc.) inside the onboarding tag step. Selecting a persona batch-follows ~10 curated tags and triggers a staggered pop animation on the corresponding tag chips so the selection feels intentional. Gated behind the new featureOnboardingPersonas GrowthBook flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/onboarding/EditTag.tsx | 12 +- .../onboarding/PersonaSelector.spec.tsx | 104 ++++++++++++ .../components/onboarding/PersonaSelector.tsx | 150 ++++++++++++++++++ .../components/onboarding/personaPopBus.ts | 14 ++ .../shared/src/components/tags/TagElement.tsx | 28 +++- packages/shared/src/graphql/feedSettings.ts | 18 +++ packages/shared/src/lib/featureManagement.ts | 5 + packages/shared/src/lib/log.ts | 3 + packages/shared/tailwind.config.ts | 16 ++ 9 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/components/onboarding/PersonaSelector.spec.tsx create mode 100644 packages/shared/src/components/onboarding/PersonaSelector.tsx create mode 100644 packages/shared/src/components/onboarding/personaPopBus.ts diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 9981ff7ffb8..c5d53edd50d 100644 --- a/packages/shared/src/components/onboarding/EditTag.tsx +++ b/packages/shared/src/components/onboarding/EditTag.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useState } from 'react'; +import classNames from 'classnames'; import { FeedPreviewControls } from '../feeds'; import { REQUIRED_TAGS_THRESHOLD } from './common'; import { Origin } from '../../lib/log'; @@ -14,6 +15,9 @@ import { useTagSearch } from '../../hooks/useTagSearch'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { SearchField } from '../fields/SearchField'; import { FunnelTargetId } from '../../features/onboarding/types/funnelEvents'; +import { PersonaSelector } from './PersonaSelector'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { featureOnboardingPersonas } from '../../lib/featureManagement'; interface EditTagProps { feedSettings: FeedSettings; @@ -45,13 +49,19 @@ export const EditTag = ({ }); const searchTags = searchResult?.searchTags.tags || []; + const { value: showPersonas } = useConditionalFeature({ + feature: featureOnboardingPersonas, + shouldEvaluate: !!feedSettings, + }); + return ( <>

{headline || 'Pick tags that are relevant to you'}

+ {showPersonas && } { + const actual = jest.requireActual('./personaPopBus'); + return { + ...actual, + broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection), + }; +}); + +const mockOnFollowTags = jest.fn().mockResolvedValue({ successful: true }); +const mockOnUnfollowTags = jest.fn().mockResolvedValue({ successful: true }); +const mockLogEvent = jest.fn(); +const mockRequest = jest.fn(); + +jest.mock('../../graphql/common', () => ({ + gqlClient: { request: (...args: unknown[]) => mockRequest(...args) }, +})); + +jest.mock('../../hooks/useTagAndSource', () => ({ + __esModule: true, + default: () => ({ + onFollowTags: mockOnFollowTags, + onUnfollowTags: mockOnUnfollowTags, + }), +})); + +jest.mock('../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +const personas = [ + { id: 'frontend', title: 'Frontend', emoji: '🌐', tags: ['react', 'css'] }, + { id: 'backend', title: 'Backend', emoji: '🖥️', tags: ['node', 'sql'] }, +]; + +const renderComponent = () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + , + ); +}; + +describe('PersonaSelector', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRequest.mockResolvedValue({ onboardingPersonas: personas }); + }); + + it('renders pills with emoji and title', async () => { + renderComponent(); + expect(await screen.findByText('Frontend')).toBeInTheDocument(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + }); + + it('follows persona tags and broadcasts on click', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => + expect(mockOnFollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + requireLogin: true, + }), + ); + expect(broadcastPersonaSelection).toHaveBeenCalledWith(['react', 'css']); + }); + + it('unfollows previous persona tags when switching', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => + expect(mockOnUnfollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + }), + ); + expect(mockOnFollowTags).toHaveBeenLastCalledWith({ + tags: ['node', 'sql'], + requireLogin: true, + }); + }); + + it('deselects on second click of same persona', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Frontend')); + await waitFor(() => + expect(mockOnUnfollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + }), + ); + expect(mockOnFollowTags).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared/src/components/onboarding/PersonaSelector.tsx b/packages/shared/src/components/onboarding/PersonaSelector.tsx new file mode 100644 index 00000000000..7f64ed466a5 --- /dev/null +++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx @@ -0,0 +1,150 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { useQuery } from '@tanstack/react-query'; +import { gqlClient } from '../../graphql/common'; +import type { GQLPersona } from '../../graphql/feedSettings'; +import { GET_ONBOARDING_PERSONAS_QUERY } from '../../graphql/feedSettings'; +import useTagAndSource from '../../hooks/useTagAndSource'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, Origin } from '../../lib/log'; +import { disabledRefetch } from '../../lib/func'; +import { RequestKey, StaleTime, generateQueryKey } from '../../lib/query'; +import { Button, ButtonColor } from '../buttons/Button'; +import { ButtonVariant } from '../buttons/common'; +import { ElementPlaceholder } from '../ElementPlaceholder'; +import { broadcastPersonaSelection } from './personaPopBus'; + +interface PersonaSelectorProps { + className?: string; + feedId?: string; +} + +export function PersonaSelector({ + className, + feedId, +}: PersonaSelectorProps): ReactElement | null { + const { logEvent } = useLogContext(); + const [activeId, setActiveId] = useState(null); + const { onFollowTags, onUnfollowTags } = useTagAndSource({ + origin: Origin.OnboardingPersona, + feedId, + }); + + const { + data: personas, + isPending, + isError, + } = useQuery({ + queryKey: generateQueryKey( + RequestKey.Tags, + undefined, + 'onboardingPersonas', + ), + queryFn: async () => { + const result = await gqlClient.request<{ + onboardingPersonas: GQLPersona[]; + }>(GET_ONBOARDING_PERSONAS_QUERY, {}); + return result.onboardingPersonas; + }, + ...disabledRefetch, + staleTime: StaleTime.OneHour, + }); + + const handleClick = async (persona: GQLPersona) => { + const previous = activeId + ? personas?.find((p) => p.id === activeId) + : undefined; + const isDeselect = activeId === persona.id; + let action: 'select' | 'switch' | 'deselect' = 'select'; + if (isDeselect) { + action = 'deselect'; + } else if (previous) { + action = 'switch'; + } + + logEvent({ + event_name: LogEvent.SelectOnboardingPersona, + target_type: 'persona', + target_id: persona.id, + extra: JSON.stringify({ + action, + tags_count: persona.tags.length, + previous_id: previous?.id, + }), + }); + + if (previous) { + await onUnfollowTags({ tags: previous.tags }); + } + if (isDeselect) { + setActiveId(null); + return; + } + + broadcastPersonaSelection(persona.tags); + await onFollowTags({ tags: persona.tags, requireLogin: true }); + setActiveId(persona.id); + }; + + if (isError) { + return null; + } + + return ( +
+ {isPending && + Array.from({ length: 10 }).map((_, i) => ( + + ))} + {!isPending && + personas?.map((persona) => { + const isActive = persona.id === activeId; + const buttonContent = ( + <> + + {persona.emoji} + + {persona.title} + + ); + + if (isActive) { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/shared/src/components/onboarding/personaPopBus.ts b/packages/shared/src/components/onboarding/personaPopBus.ts new file mode 100644 index 00000000000..13e11285fad --- /dev/null +++ b/packages/shared/src/components/onboarding/personaPopBus.ts @@ -0,0 +1,14 @@ +type Listener = (tagNames: string[]) => void; + +const listeners = new Set(); + +export function subscribePersonaSelection(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function broadcastPersonaSelection(tagNames: string[]): void { + listeners.forEach((listener) => listener(tagNames)); +} diff --git a/packages/shared/src/components/tags/TagElement.tsx b/packages/shared/src/components/tags/TagElement.tsx index a7086ca6d57..27ee3d5e572 100644 --- a/packages/shared/src/components/tags/TagElement.tsx +++ b/packages/shared/src/components/tags/TagElement.tsx @@ -1,11 +1,12 @@ import classNames from 'classnames'; import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { AlertDot, AlertColor } from '../AlertDot'; import { Button, ButtonColor } from '../buttons/Button'; import { ButtonVariant } from '../buttons/common'; import type { Tag } from '../../graphql/feedSettings'; import type { OnSelectTagProps } from './common'; +import { subscribePersonaSelection } from '../onboarding/personaPopBus'; export type OnboardingTagProps = { tag: Tag; @@ -21,7 +22,26 @@ export const TagElement = ({ isHighlighted = false, ...attrs }: OnboardingTagProps): ReactElement => { - const className = classNames({ 'btn-tag': !isSelected }, 'relative'); + const [popDelayMs, setPopDelayMs] = useState(null); + + useEffect(() => { + return subscribePersonaSelection((tagNames) => { + const idx = tagNames.indexOf(tag.name ?? ''); + if (idx === -1) { + return; + } + setPopDelayMs(Math.min(idx, 9) * 60); + }); + }, [tag.name]); + + const className = classNames( + { 'btn-tag': !isSelected }, + 'relative', + popDelayMs !== null && 'animate-tag-pop', + ); + const style = + popDelayMs !== null ? { animationDelay: `${popDelayMs}ms` } : undefined; + const handleAnimationEnd = () => setPopDelayMs(null); const handleClick = () => onClick({ tag }); const content = ( <> @@ -39,6 +59,8 @@ export const TagElement = ({ return (