diff --git a/packages/shared/src/components/onboarding/EditTag.tsx b/packages/shared/src/components/onboarding/EditTag.tsx index 9981ff7ffb8..f8eb0ef9db6 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,26 @@ 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 && ( + <> +

+ Quick start: pick up to 3 roles to follow related tags. +

+ + + )} { + const actual = jest.requireActual('./onboardingPopBus'); + return { + ...actual, + broadcastPersonaSelection: jest.fn(actual.broadcastPersonaSelection), + broadcastRecommendRequest: jest.fn(actual.broadcastRecommendRequest), + }; +}); + +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'] }, + { id: 'mobile', title: 'Mobile', emoji: '📱', tags: ['ios', 'android'] }, + { id: 'devops', title: 'DevOps', emoji: '☁️', tags: ['docker', 'k8s'] }, +]; + +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 tags and broadcasts pop + recommend 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']); + expect(broadcastRecommendRequest).toHaveBeenCalledWith(['react', 'css']); + }); + + it('allows multi-select without unfollowing previous persona', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + + expect(mockOnFollowTags).toHaveBeenLastCalledWith({ + tags: ['node', 'sql'], + requireLogin: true, + }); + expect(mockOnUnfollowTags).not.toHaveBeenCalled(); + }); + + it('disables additional personas after 3 are selected', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByText('Mobile')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(3)); + + const devopsButton = screen.getByText('DevOps').closest('button'); + expect(devopsButton).toBeDisabled(); + + fireEvent.click(screen.getByText('DevOps')); + expect(mockOnFollowTags).toHaveBeenCalledTimes(3); + }); + + it('deselects only the clicked persona', async () => { + renderComponent(); + fireEvent.click(await screen.findByText('Frontend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText('Backend')); + await waitFor(() => expect(mockOnFollowTags).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByText('Frontend')); + await waitFor(() => + expect(mockOnUnfollowTags).toHaveBeenCalledWith({ + tags: ['react', 'css'], + }), + ); + expect(mockOnUnfollowTags).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..49d838436d4 --- /dev/null +++ b/packages/shared/src/components/onboarding/PersonaSelector.tsx @@ -0,0 +1,161 @@ +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, + broadcastRecommendRequest, +} from './onboardingPopBus'; + +export const MAX_PERSONAS = 3; + +interface PersonaSelectorProps { + className?: string; + feedId?: string; +} + +export function PersonaSelector({ + className, + feedId, +}: PersonaSelectorProps): ReactElement | null { + const { logEvent } = useLogContext(); + const [activeIds, setActiveIds] = useState>(new Set()); + 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 isActive = activeIds.has(persona.id); + const isAtCap = !isActive && activeIds.size >= MAX_PERSONAS; + if (isAtCap) { + return; + } + + logEvent({ + event_name: LogEvent.SelectOnboardingPersona, + target_type: 'persona', + target_id: persona.id, + extra: JSON.stringify({ + action: isActive ? 'deselect' : 'select', + tags_count: persona.tags.length, + active_count: isActive ? activeIds.size - 1 : activeIds.size + 1, + }), + }); + + if (isActive) { + await onUnfollowTags({ tags: persona.tags }); + setActiveIds((prev) => { + const next = new Set(prev); + next.delete(persona.id); + return next; + }); + return; + } + + broadcastPersonaSelection(persona.tags); + await onFollowTags({ tags: persona.tags, requireLogin: true }); + broadcastRecommendRequest(persona.tags); + setActiveIds((prev) => { + const next = new Set(prev); + next.add(persona.id); + return next; + }); + }; + + if (isError) { + return null; + } + + const isAtCap = activeIds.size >= MAX_PERSONAS; + + return ( +
+ {isPending && + Array.from({ length: 10 }).map((_, i) => ( + + ))} + {!isPending && + personas?.map((persona) => { + const isActive = activeIds.has(persona.id); + const isDisabled = !isActive && isAtCap; + const buttonContent = ( + <> + + {persona.emoji} + + {persona.title} + + ); + + if (isActive) { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/shared/src/components/onboarding/onboardingPopBus.ts b/packages/shared/src/components/onboarding/onboardingPopBus.ts new file mode 100644 index 00000000000..b08df68f5f8 --- /dev/null +++ b/packages/shared/src/components/onboarding/onboardingPopBus.ts @@ -0,0 +1,29 @@ +type PopListener = (tagNames: string[]) => void; +type RecommendListener = (tags: string[]) => void; + +const popListeners = new Set(); +const recommendListeners = new Set(); + +export function subscribePersonaSelection(listener: PopListener): () => void { + popListeners.add(listener); + return () => { + popListeners.delete(listener); + }; +} + +export function broadcastPersonaSelection(tagNames: string[]): void { + popListeners.forEach((listener) => listener(tagNames)); +} + +export function subscribeRecommendRequest( + listener: RecommendListener, +): () => void { + recommendListeners.add(listener); + return () => { + recommendListeners.delete(listener); + }; +} + +export function broadcastRecommendRequest(tags: string[]): void { + recommendListeners.forEach((listener) => listener(tags)); +} diff --git a/packages/shared/src/components/tags/TagElement.tsx b/packages/shared/src/components/tags/TagElement.tsx index a7086ca6d57..26bfab8a76d 100644 --- a/packages/shared/src/components/tags/TagElement.tsx +++ b/packages/shared/src/components/tags/TagElement.tsx @@ -1,17 +1,22 @@ import classNames from 'classnames'; -import type { ReactElement } from 'react'; -import React from 'react'; +import type { AnimationEvent, ReactElement } from 'react'; +import React, { useEffect, useMemo, 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/onboardingPopBus'; + +const SPARK_COUNT = 5; export type OnboardingTagProps = { tag: Tag; onClick: (props: Pick) => void; isSelected?: boolean; isHighlighted?: boolean; + isExiting?: boolean; + onExited?: (tagName: string) => void; }; export const TagElement = ({ @@ -19,10 +24,63 @@ export const TagElement = ({ onClick, isSelected = false, isHighlighted = false, + isExiting = false, + onExited, ...attrs }: OnboardingTagProps): ReactElement => { - const className = classNames({ 'btn-tag': !isSelected }, 'relative'); - const handleClick = () => onClick({ tag }); + 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 isPopping = popDelayMs !== null && !isExiting; + + const sparks = useMemo(() => { + if (!isPopping) { + return []; + } + return Array.from({ length: SPARK_COUNT }, (_, i) => { + const angle = (i / SPARK_COUNT) * Math.PI * 2 + Math.random() * 0.6; + const radius = 18 + Math.random() * 12; + return { + fx: `${Math.cos(angle) * radius}px`, + fy: `${Math.sin(angle) * radius}px`, + }; + }); + }, [isPopping]); + + const className = classNames( + { 'btn-tag': !isSelected }, + 'relative', + isExiting && 'pointer-events-none animate-tag-fade-out', + !isExiting && isPopping && 'animate-tag-pop', + ); + const style = + !isExiting && isPopping ? { animationDelay: `${popDelayMs}ms` } : undefined; + const handleAnimationEnd = (event: AnimationEvent) => { + if (event.animationName === 'tag-fade-out') { + if (tag.name) { + onExited?.(tag.name); + } + return; + } + if (event.animationName === 'tag-pop') { + setPopDelayMs(null); + } + }; + const handleClick = () => { + if (isExiting) { + return; + } + onClick({ tag }); + }; const content = ( <> {tag.name} @@ -32,6 +90,25 @@ export const TagElement = ({ color={AlertColor.Cabbage} /> )} + {isPopping && ( + + {sparks.map((spark, i) => ( + + ))} + + )} ); @@ -39,6 +116,8 @@ export const TagElement = ({ return (