diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--dark.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--dark.png new file mode 100644 index 0000000000..ba6b9f8ab4 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--light.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--light.png new file mode 100644 index 0000000000..3043edcfc4 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--desktop-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--dark.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--dark.png new file mode 100644 index 0000000000..44e66e09ef Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--light.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--light.png new file mode 100644 index 0000000000..a5aa0a6659 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--mobile-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--dark.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--dark.png new file mode 100644 index 0000000000..1d092f106c Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--light.png b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--light.png new file mode 100644 index 0000000000..a1fc8060b4 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-onboarding-use-case-selection--tablet-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--desktop-view--dark.png b/frontend/__snapshots__/scenes-other-products--desktop-view--dark.png index 51773e0ecf..cdba677c1e 100644 Binary files a/frontend/__snapshots__/scenes-other-products--desktop-view--dark.png and b/frontend/__snapshots__/scenes-other-products--desktop-view--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--desktop-view--light.png b/frontend/__snapshots__/scenes-other-products--desktop-view--light.png index 1a77540250..86a13f350b 100644 Binary files a/frontend/__snapshots__/scenes-other-products--desktop-view--light.png and b/frontend/__snapshots__/scenes-other-products--desktop-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--dark.png b/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--dark.png new file mode 100644 index 0000000000..eaf822828e Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--light.png b/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--light.png new file mode 100644 index 0000000000..91f9d0c5b1 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--fix-issues-use-case--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--mobile-view--dark.png b/frontend/__snapshots__/scenes-other-products--mobile-view--dark.png index 4d218dcfe3..2a7063a0c1 100644 Binary files a/frontend/__snapshots__/scenes-other-products--mobile-view--dark.png and b/frontend/__snapshots__/scenes-other-products--mobile-view--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--mobile-view--light.png b/frontend/__snapshots__/scenes-other-products--mobile-view--light.png index cf3497c1fe..3dec2ad61f 100644 Binary files a/frontend/__snapshots__/scenes-other-products--mobile-view--light.png and b/frontend/__snapshots__/scenes-other-products--mobile-view--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--pick-myself-layout--dark.png b/frontend/__snapshots__/scenes-other-products--pick-myself-layout--dark.png new file mode 100644 index 0000000000..cdba677c1e Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--pick-myself-layout--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--pick-myself-layout--light.png b/frontend/__snapshots__/scenes-other-products--pick-myself-layout--light.png new file mode 100644 index 0000000000..86a13f350b Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--pick-myself-layout--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--dark.png b/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--dark.png new file mode 100644 index 0000000000..60b4344b4b Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--light.png b/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--light.png new file mode 100644 index 0000000000..efe3fffc85 Binary files /dev/null and b/frontend/__snapshots__/scenes-other-products--with-use-case-recommendations--light.png differ diff --git a/frontend/src/layout/navigation/ProjectNotice.tsx b/frontend/src/layout/navigation/ProjectNotice.tsx index 6046a4c884..968a3969d5 100644 --- a/frontend/src/layout/navigation/ProjectNotice.tsx +++ b/frontend/src/layout/navigation/ProjectNotice.tsx @@ -4,11 +4,13 @@ import { useEffect, useState } from 'react' import { IconGear, IconPlus } from '@posthog/icons' import { Spinner } from '@posthog/lemon-ui' +import { FEATURE_FLAGS } from 'lib/constants' import { dayjs } from 'lib/dayjs' import { useOnMountEffect } from 'lib/hooks/useOnMountEffect' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonBannerAction } from 'lib/lemon-ui/LemonBanner/LemonBanner' import { Link } from 'lib/lemon-ui/Link' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { cn } from 'lib/utils/css-classes' import { verifyEmailLogic } from 'scenes/authentication/signup/verify-email/verifyEmailLogic' import { organizationLogic } from 'scenes/organizationLogic' @@ -64,12 +66,14 @@ export function ProjectNotice({ className }: { className?: string }): JSX.Elemen const { showInviteModal } = useActions(inviteLogic) const { requestVerificationLink } = useActions(verifyEmailLogic) const { sceneConfig, productFromUrl } = useValues(sceneLogic) + const { featureFlags } = useValues(featureFlagLogic) if (!projectNoticeVariant) { return null } const altTeamForIngestion = currentOrganization?.teams?.find((team) => !team.is_demo && !team.ingested_event) + const useUseCaseSelection = featureFlags[FEATURE_FLAGS.ONBOARDING_USE_CASE_SELECTION] === true const NOTICES: Record = { demo_project: { @@ -81,7 +85,10 @@ export function ProjectNotice({ className }: { className?: string }): JSX.Elemen {' '} When you're ready, head on over to the{' '} onboarding wizard diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 3e98002917..dd69d691e9 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -230,6 +230,7 @@ export const FEATURE_FLAGS = { SCHEMA_MANAGEMENT: 'schema-management', // owner: @aspicer SDK_DOCTOR_BETA: 'sdk-doctor-beta', // owner: @slshults ONBOARDING_DATA_WAREHOUSE_FOR_PRODUCT_ANALYTICS: 'onboarding-data-warehouse-for-product-analytics', // owner: @joshsny + ONBOARDING_USE_CASE_SELECTION: 'onboarding-use-case-selection', // owner: @mattbro DELAYED_LOADING_ANIMATION: 'delayed-loading-animation', // owner: @raquelmsmith WEB_ANALYTICS_PAGE_REPORTS: 'web-analytics-page-reports', // owner: @lricoy #team-web-analytics ENDPOINTS: 'embedded-analytics', // owner: @sakce #team-clickhouse diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index 2310530bb6..fb82c95509 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -599,6 +599,10 @@ export const eventUsageLogic = kea([ reportSurveyCycleDetected: (survey: Survey | NewSurvey) => ({ survey }), reportProductUnsubscribed: (product: string) => ({ product }), reportSubscribedDuringOnboarding: (productKey: string) => ({ productKey }), + reportOnboardingUseCaseSelected: (useCase: string, recommendedProducts: readonly string[]) => ({ + useCase, + recommendedProducts, + }), // command bar reportCommandBarStatusChanged: (status: BarStatus) => ({ status }), reportCommandBarSearch: (queryLength: number) => ({ queryLength }), @@ -1445,6 +1449,12 @@ export const eventUsageLogic = kea([ product_key: productKey, }) }, + reportOnboardingUseCaseSelected: ({ useCase, recommendedProducts }) => { + posthog.capture('onboarding use case selected', { + use_case: useCase, + recommended_products: recommendedProducts, + }) + }, reportSDKSelected: ({ sdk }) => { posthog.capture('sdk selected', { sdk: sdk.key, diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index e408368d85..68abb69c2a 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -73,6 +73,7 @@ export const appScenes: Record any> = { [Scene.Persons]: () => import('./persons/PersonsScene'), [Scene.PreflightCheck]: () => import('./PreflightCheck/PreflightCheck'), [Scene.Products]: () => import('./products/Products'), + [Scene.UseCaseSelection]: () => import('./onboarding/useCaseSelection/UseCaseSelection'), [Scene.ProjectCreateFirst]: () => import('./project/Create'), [Scene.ProjectHomepage]: () => import('./project-homepage/ProjectHomepage'), [Scene.PropertyDefinitionEdit]: () => import('./data-management/definition/DefinitionEdit'), diff --git a/frontend/src/scenes/onboarding/productRecommendations.ts b/frontend/src/scenes/onboarding/productRecommendations.ts new file mode 100644 index 0000000000..af95dde40f --- /dev/null +++ b/frontend/src/scenes/onboarding/productRecommendations.ts @@ -0,0 +1,88 @@ +import { ProductKey } from '~/types' + +export type UseCaseOption = + | 'see_user_behavior' + | 'fix_issues' + | 'launch_features' + | 'collect_feedback' + | 'monitor_ai' + | 'pick_myself' + +export interface UseCaseDefinition { + key: UseCaseOption + title: string + description: string + iconKey: string + iconColor: string + products: readonly ProductKey[] +} + +// Single source of truth for use case definitions +// Used by both UseCaseSelection.tsx UI and product recommendation logic +// Note: Keep descriptions under 88 characters for better readability +export const USE_CASE_OPTIONS: ReadonlyArray = [ + { + key: 'see_user_behavior', + title: 'Understand how users behave', + description: 'Track website traffic and user behavior with analytics and conversion funnels', + iconKey: 'IconGraph', + iconColor: 'rgb(47 128 250)', + products: [ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY, ProductKey.WEB_ANALYTICS], + }, + { + key: 'fix_issues', + title: 'Find and fix issues', + description: 'Watch session recordings and monitor errors to debug issues', + iconKey: 'IconWarning', + iconColor: 'rgb(235 157 42)', + products: [ProductKey.SESSION_REPLAY, ProductKey.ERROR_TRACKING], + }, + { + key: 'launch_features', + title: 'Launch features with confidence', + description: 'Roll out features gradually and run A/B tests to optimize your product', + iconKey: 'IconToggle', + iconColor: 'rgb(48 171 198)', + products: [ProductKey.FEATURE_FLAGS, ProductKey.EXPERIMENTS], + }, + { + key: 'collect_feedback', + title: 'Collect user feedback', + description: 'Collect feedback with in-app surveys and watch session recordings', + iconKey: 'IconMessage', + iconColor: 'rgb(243 84 84)', + products: [ProductKey.SURVEYS, ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY], + }, + { + key: 'monitor_ai', + title: 'Monitor AI applications', + description: 'Track and analyze LLM usage, costs, and performance for AI applications', + iconKey: 'IconLlmAnalytics', + iconColor: 'rgb(182 42 217)', + products: [ProductKey.LLM_ANALYTICS, ProductKey.PRODUCT_ANALYTICS], + }, +] as const + +// 'pick_myself' is handled separately as it has no products or UI representation in the selection list + +// Note: Data Warehouse is NOT in any recommendations +// It's only available via "Show all products" button +// Why? It's an advanced product typically used by data teams +// who know they need it. Not a good starter product for onboarding. +// Users who need it will find it in the expanded list. + +export function getRecommendedProducts(useCase: UseCaseOption | null | string): readonly ProductKey[] { + if (!useCase || useCase === 'pick_myself') { + return [] + } + const option = USE_CASE_OPTIONS.find((opt) => opt.key === useCase) + return option?.products || [] +} + +export function getUseCaseLabel(useCase: UseCaseOption | null | string): string { + if (useCase === 'pick_myself') { + return 'I want to pick products myself' + } + const option = USE_CASE_OPTIONS.find((opt) => opt.key === useCase) + return option?.title || '' +} diff --git a/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.stories.tsx b/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.stories.tsx new file mode 100644 index 0000000000..2fd0df9d34 --- /dev/null +++ b/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.stories.tsx @@ -0,0 +1,67 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { App } from 'scenes/App' +import { urls } from 'scenes/urls' + +import { mswDecorator } from '~/mocks/browser' +import { billingJson } from '~/mocks/fixtures/_billing' +import preflightJson from '~/mocks/fixtures/_preflight.json' + +const meta: Meta = { + component: App, + title: 'Scenes-Other/Onboarding/Use Case Selection', + parameters: { + layout: 'fullscreen', + viewMode: 'story', + mockDate: '2023-05-25', + pageUrl: urls.useCaseSelection(), + }, + decorators: [ + mswDecorator({ + get: { + '/api/billing/': { ...billingJson }, + '/_preflight': { + ...preflightJson, + cloud: true, + realm: 'cloud', + }, + }, + }), + ], +} +export default meta + +type Story = StoryObj + +export const DesktopView: Story = { + parameters: { + testOptions: { + viewport: { + width: 2048, + height: 1024, + }, + }, + }, +} + +export const MobileView: Story = { + parameters: { + testOptions: { + viewport: { + width: 568, + height: 1024, + }, + }, + }, +} + +export const TabletView: Story = { + parameters: { + testOptions: { + viewport: { + width: 768, + height: 1024, + }, + }, + }, +} diff --git a/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.tsx b/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.tsx new file mode 100644 index 0000000000..4c571b2734 --- /dev/null +++ b/frontend/src/scenes/onboarding/useCaseSelection/UseCaseSelection.tsx @@ -0,0 +1,69 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton } from '@posthog/lemon-ui' +import { LemonCard } from '@posthog/lemon-ui' + +import { onboardingLogic } from 'scenes/onboarding/onboardingLogic' +import { USE_CASE_OPTIONS } from 'scenes/onboarding/productRecommendations' +import { getProductIcon } from 'scenes/products/Products' +import { SceneExport } from 'scenes/sceneTypes' +import { teamLogic } from 'scenes/teamLogic' + +import { useCaseSelectionLogic } from './useCaseSelectionLogic' + +export const scene: SceneExport = { + component: UseCaseSelection, +} + +export function UseCaseSelection(): JSX.Element { + const { selectUseCase } = useActions(useCaseSelectionLogic) + const { skipOnboarding } = useActions(onboardingLogic) + const { hasIngestedEvent } = useValues(teamLogic) + + return ( +
+
+

What do you want to do with PostHog?

+

Select your primary goal to get started:

+ +
+ {USE_CASE_OPTIONS.map((useCase) => ( + selectUseCase(useCase.key)} + hoverEffect + > +
+
+ {getProductIcon(useCase.iconColor, useCase.iconKey, 'text-3xl')} +
+
+

{useCase.title}

+

{useCase.description}

+
+
+
+ ))} +
+ +
+ {hasIngestedEvent ? ( + skipOnboarding()}> + Skip onboarding + + ) : ( +
// Spacer to keep "pick myself" on the right + )} + + +
+
+
+ ) +} diff --git a/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.test.ts b/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.test.ts new file mode 100644 index 0000000000..73dfb24d30 --- /dev/null +++ b/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.test.ts @@ -0,0 +1,86 @@ +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' +import posthog from 'posthog-js' + +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' + +import { getRecommendedProducts } from '../productRecommendations' +import { useCaseSelectionLogic } from './useCaseSelectionLogic' + +describe('useCaseSelectionLogic', () => { + let logic: ReturnType + + beforeEach(() => { + useMocks({}) + initKeaTests() + jest.spyOn(posthog, 'capture') + logic = useCaseSelectionLogic() + logic.mount() + }) + + describe('selectUseCase', () => { + it('navigates to products page with useCase param', async () => { + await expectLogic(logic, () => { + logic.actions.selectUseCase('see_user_behavior') + }) + + expect(router.values.location.pathname).toContain('/products') + expect(router.values.searchParams.useCase).toBe('see_user_behavior') + }) + + it('captures analytics event with use case and recommended products', async () => { + await expectLogic(logic, () => { + logic.actions.selectUseCase('fix_issues') + }) + .toDispatchActions(eventUsageLogic, ['reportOnboardingUseCaseSelected']) + .toFinishListeners() + + await expectLogic(eventUsageLogic).toFinishListeners() + + expect(posthog.capture).toHaveBeenCalledWith('onboarding use case selected', { + use_case: 'fix_issues', + recommended_products: getRecommendedProducts('fix_issues'), + }) + }) + + it('handles pick_myself use case', async () => { + await expectLogic(logic, () => { + logic.actions.selectUseCase('pick_myself') + }) + + expect(router.values.location.pathname).toContain('/products') + expect(router.values.searchParams.useCase).toBe('pick_myself') + // pick_myself should NOT trigger the analytics event + expect(posthog.capture).not.toHaveBeenCalled() + }) + + it('handles different use cases', async () => { + const useCases: Array< + 'see_user_behavior' | 'fix_issues' | 'launch_features' | 'collect_feedback' | 'monitor_ai' + > = ['see_user_behavior', 'fix_issues', 'launch_features', 'collect_feedback', 'monitor_ai'] + + for (const useCase of useCases) { + jest.clearAllMocks() + + await expectLogic(logic, () => { + logic.actions.selectUseCase(useCase) + }) + .toDispatchActions(eventUsageLogic, ['reportOnboardingUseCaseSelected']) + .toFinishListeners() + + await expectLogic(eventUsageLogic).toFinishListeners() + + expect(router.values.searchParams.useCase).toBe(useCase) + expect(posthog.capture).toHaveBeenCalledWith( + 'onboarding use case selected', + expect.objectContaining({ + use_case: useCase, + }) + ) + } + }) + }) +}) diff --git a/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.ts b/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.ts new file mode 100644 index 0000000000..4763eaf158 --- /dev/null +++ b/frontend/src/scenes/onboarding/useCaseSelection/useCaseSelectionLogic.ts @@ -0,0 +1,29 @@ +import { actions, connect, kea, listeners, path } from 'kea' +import { router } from 'kea-router' + +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import { urls } from 'scenes/urls' + +import { UseCaseOption, getRecommendedProducts } from '../productRecommendations' +import type { useCaseSelectionLogicType } from './useCaseSelectionLogicType' + +export const useCaseSelectionLogic = kea([ + path(['scenes', 'onboarding', 'useCaseSelectionLogic']), + connect({ + actions: [eventUsageLogic, ['reportOnboardingUseCaseSelected']], + }), + + actions({ + selectUseCase: (useCase: UseCaseOption) => ({ useCase }), + }), + + listeners(({ actions }) => ({ + selectUseCase: ({ useCase }: { useCase: UseCaseOption }) => { + if (useCase !== 'pick_myself') { + actions.reportOnboardingUseCaseSelected(useCase, getRecommendedProducts(useCase)) + } + + router.actions.push(urls.products(), { useCase }) + }, + })), +]) diff --git a/frontend/src/scenes/organizationLogic.tsx b/frontend/src/scenes/organizationLogic.tsx index 8ea18979f7..2c5dee870d 100644 --- a/frontend/src/scenes/organizationLogic.tsx +++ b/frontend/src/scenes/organizationLogic.tsx @@ -4,9 +4,10 @@ import { router } from 'kea-router' import api, { ApiConfig } from 'lib/api' import { timeSensitiveAuthenticationLogic } from 'lib/components/TimeSensitiveAuthentication/timeSensitiveAuthenticationLogic' -import { OrganizationMembershipLevel } from 'lib/constants' +import { FEATURE_FLAGS, OrganizationMembershipLevel } from 'lib/constants' import { dayjs } from 'lib/dayjs' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { isUserLoggedIn } from 'lib/utils' import { getAppContext } from 'lib/utils/getAppContext' @@ -44,6 +45,9 @@ export const organizationLogic = kea([ deleteOrganizationSuccess: ({ redirectPath }: { redirectPath?: string }) => ({ redirectPath }), deleteOrganizationFailure: true, }), + connect({ + values: [featureFlagLogic, ['featureFlags']], + }), connect([userLogic]), reducers({ organizationBeingDeleted: [ @@ -137,7 +141,7 @@ export const organizationLogic = kea([ }, ], }), - listeners(({ actions }) => ({ + listeners(({ actions, values }) => ({ loadCurrentOrganizationSuccess: ({ currentOrganization }) => { if (currentOrganization) { ApiConfig.setCurrentOrganizationId(currentOrganization.id) @@ -145,7 +149,8 @@ export const organizationLogic = kea([ }, createOrganizationSuccess: () => { sidePanelStateLogic.findMounted()?.actions.closeSidePanel() - window.location.href = urls.products() + const useUseCaseSelection = values.featureFlags[FEATURE_FLAGS.ONBOARDING_USE_CASE_SELECTION] === true + window.location.href = useUseCaseSelection ? urls.useCaseSelection() : urls.products() }, updateOrganizationSuccess: () => { lemonToast.success('Organization updated successfully!') diff --git a/frontend/src/scenes/products/Products.stories.tsx b/frontend/src/scenes/products/Products.stories.tsx index fa7d0eff4c..8fe1657471 100644 --- a/frontend/src/scenes/products/Products.stories.tsx +++ b/frontend/src/scenes/products/Products.stories.tsx @@ -32,6 +32,7 @@ const meta: Meta = { export default meta type Story = StoryObj + export const DesktopView: Story = { parameters: { testOptions: { @@ -53,3 +54,39 @@ export const MobileView: Story = { }, }, } + +export const WithUseCaseRecommendations: Story = { + parameters: { + pageUrl: urls.products() + '?useCase=see_user_behavior', + testOptions: { + viewport: { + width: 2048, + height: 1024, + }, + }, + }, +} + +export const PickMyselfLayout: Story = { + parameters: { + pageUrl: urls.products() + '?useCase=pick_myself', + testOptions: { + viewport: { + width: 2048, + height: 1024, + }, + }, + }, +} + +export const FixIssuesUseCase: Story = { + parameters: { + pageUrl: urls.products() + '?useCase=fix_issues', + testOptions: { + viewport: { + width: 2048, + height: 1024, + }, + }, + }, +} diff --git a/frontend/src/scenes/products/Products.tsx b/frontend/src/scenes/products/Products.tsx index e67ed17133..c44273bade 100644 --- a/frontend/src/scenes/products/Products.tsx +++ b/frontend/src/scenes/products/Products.tsx @@ -1,17 +1,19 @@ import clsx from 'clsx' import { useActions, useValues } from 'kea' import { router } from 'kea-router' +import { useState } from 'react' import * as Icons from '@posthog/icons' -import { IconArrowRight, IconCheckCircle } from '@posthog/icons' +import { IconArrowRight, IconChevronDown } from '@posthog/icons' import { LemonButton, LemonLabel, LemonSelect, Link, Tooltip } from '@posthog/lemon-ui' import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' -import { getProductUri, onboardingLogic } from 'scenes/onboarding/onboardingLogic' +import { onboardingLogic } from 'scenes/onboarding/onboardingLogic' import { availableOnboardingProducts } from 'scenes/onboarding/utils' import { SceneExport } from 'scenes/sceneTypes' import { inviteLogic } from 'scenes/settings/organization/inviteLogic' import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' import { OnboardingProduct, ProductKey } from '~/types' @@ -21,8 +23,15 @@ export const scene: SceneExport = { component: Products, } +const isValidIconKey = (key: string): key is keyof typeof Icons => key in Icons +type AvailableOnboardingProductKey = keyof typeof availableOnboardingProducts +const AVAILABLE_ONBOARDING_PRODUCT_KEYS = Object.keys(availableOnboardingProducts) as AvailableOnboardingProductKey[] +const isAvailableOnboardingProductKey = (key: string | ProductKey): key is AvailableOnboardingProductKey => + key in availableOnboardingProducts + export function getProductIcon(color: string, iconKey?: string | null, className?: string): JSX.Element { - const Icon = Icons[iconKey || ('IconLogomark' as keyof typeof Icons)] + const resolvedKey: keyof typeof Icons = iconKey && isValidIconKey(iconKey) ? iconKey : 'IconLogomark' + const Icon = Icons[resolvedKey] as (props: { className?: string; color?: string }) => JSX.Element return } @@ -31,6 +40,7 @@ export function SelectableProductCard({ productKey, onClick, orientation = 'vertical', + showDescription = false, className, selected = false, }: { @@ -38,6 +48,7 @@ export function SelectableProductCard({ productKey: string onClick: () => void orientation?: 'horizontal' | 'vertical' + showDescription?: boolean className?: string selected?: boolean }): JSX.Element { @@ -45,144 +56,251 @@ export function SelectableProductCard({ const onboardingCompleted = currentTeam?.has_completed_onboarding_for?.[productKey] const vertical = orientation === 'vertical' - return ( - + {!showDescription && product.description && ( <> {product.description}
- {onboardingCompleted && You've already set up this app. Click to return to its page.} - } + )} + {onboardingCompleted && You've already set up this app. Click to return to its page.} + + ) + + const card = ( + - - {onboardingCompleted && ( -
{ - e.stopPropagation() - router.actions.push(getProductUri(productKey as ProductKey)) - }} - data-attr={`return-to-${productKey}`} - > - -
- )} + {vertical ? ( + // Vertical layout
{getProductIcon(product.iconColor, product.icon, 'text-2xl')}
{product.name}
-
-
+ ) : ( + // Horizontal layout with description +
+
+ {getProductIcon(product.iconColor, product.icon, 'text-3xl')} +
+
+

{product.name}

+ {showDescription && description &&

{description}

} +
+
+ )} + ) + + return shouldShowTooltip ? {card} : card } export function Products(): JSX.Element { const { showInviteModal } = useActions(inviteLogic) - const { toggleSelectedProduct, setFirstProductOnboarding, handleStartOnboarding } = useActions(productsLogic) - const { selectedProducts, firstProductOnboarding } = useValues(productsLogic) + const { selectedProducts, firstProductOnboarding, preSelectedProducts, useCase, isUseCaseOnboardingEnabled } = + useValues(productsLogic) const { skipOnboarding } = useActions(onboardingLogic) const { hasIngestedEvent } = useValues(teamLogic) + const [showAllProducts, setShowAllProducts] = useState(false) + + // Get all non-recommended products + const availablePreSelectedProducts = preSelectedProducts.filter(isAvailableOnboardingProductKey) + const otherProducts = AVAILABLE_ONBOARDING_PRODUCT_KEYS.filter((key) => !availablePreSelectedProducts.includes(key)) return (
<> + {/* Back button at the top */} + {isUseCaseOnboardingEnabled && ( +
+ +
+ )} +
-
+

Which apps would you like to use?

-

- Don't worry – you can pick more than one! Please select all that apply. +

+ {isUseCaseOnboardingEnabled + ? `We've pre-selected some products based on your goal. Feel free to change or add more.` + : "Don't worry – you can pick more than one! Please select all that apply."}

-
-
- {Object.keys(availableOnboardingProducts).map((productKey) => ( - { - toggleSelectedProduct(productKey as ProductKey) - }} - className="w-[160px]" - selected={selectedProducts.includes(productKey as ProductKey)} - /> - ))} -
-
- {hasIngestedEvent && ( - { - skipOnboarding() - }} - > - Skip onboarding - - )} - {selectedProducts.length > 1 ? ( -
- Start first with - ({ - label: - availableOnboardingProducts[ - productKey as keyof typeof availableOnboardingProducts - ]?.name ?? '', - value: productKey, - }))} - onChange={(value) => value && setFirstProductOnboarding(value)} - placeholder="Select a product" - className="bg-surface-primary" +
+ {isUseCaseOnboardingEnabled ? ( + // NEW LAYOUT: Horizontal cards with recommendations (when use case onboarding is enabled) +
+ {/* Recommended products - always shown if we have them */} + {availablePreSelectedProducts.length > 0 && ( +
+ {availablePreSelectedProducts.map((productKey) => ( + toggleSelectedProduct(productKey)} + selected={selectedProducts.includes(productKey)} + orientation="horizontal" + showDescription={true} + className="w-full" + /> + ))} +
+ )} + + {/* Other products section - always rendered to avoid layout shift */} + {availablePreSelectedProducts.length > 0 && otherProducts.length > 0 && ( +
+ {/* Toggle button */} + + + {/* Products with smooth height transition */} +
+
+ {otherProducts.map((productKey) => ( + toggleSelectedProduct(productKey)} + selected={selectedProducts.includes(productKey)} + orientation="horizontal" + showDescription={true} + className="w-full" + /> + ))} +
+
+
+ )} +
+ ) : ( + // OLD LAYOUT: Flex wrap layout (when use case onboarding is disabled) +
+ {AVAILABLE_ONBOARDING_PRODUCT_KEYS.map((productKey) => ( + toggleSelectedProduct(productKey)} + selected={selectedProducts.includes(productKey)} + orientation="vertical" + showDescription={false} + className="w-[160px]" /> + ))} +
+ )} + +
+
+ {hasIngestedEvent && ( + { + skipOnboarding() + }} + > + Skip onboarding + + )} + {selectedProducts.length > 1 ? ( +
+ Start with + ({ + label: isAvailableOnboardingProductKey(productKey) + ? availableOnboardingProducts[productKey].name + : productKey, + value: productKey, + }))} + onChange={(value) => value && setFirstProductOnboarding(value)} + placeholder="Select a product" + className="bg-surface-primary" + /> + } + onClick={handleStartOnboarding} + type="primary" + status="alt" + data-attr="onboarding-continue" + > + Go + +
+ ) : ( } - onClick={handleStartOnboarding} type="primary" status="alt" + onClick={handleStartOnboarding} data-attr="onboarding-continue" + sideIcon={} + disabledReason={ + selectedProducts.length === 0 ? 'Select a product to start with' : undefined + } > - Go + Get started -
- ) : ( - } - disabledReason={ - selectedProducts.length === 0 ? 'Select a product to start with' : undefined - } - > - Get started - - )} + )} +
diff --git a/frontend/src/scenes/products/productsLogic.test.ts b/frontend/src/scenes/products/productsLogic.test.ts new file mode 100644 index 0000000000..974550e805 --- /dev/null +++ b/frontend/src/scenes/products/productsLogic.test.ts @@ -0,0 +1,194 @@ +import { MOCK_TEAM_ID } from 'lib/api.mock' + +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' + +import { useMocks } from '~/mocks/jest' +import { initKeaTests } from '~/test/init' +import { ProductKey } from '~/types' + +import { productsLogic } from './productsLogic' + +describe('productsLogic', () => { + let logic: ReturnType + + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team_id/feature_flags/my_flags': {}, + }, + }) + initKeaTests() + logic = productsLogic() + logic.mount() + }) + + describe('product selection', () => { + it('adds a product when toggled', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + }).toMatchValues({ + selectedProducts: [ProductKey.PRODUCT_ANALYTICS], + firstProductOnboarding: ProductKey.PRODUCT_ANALYTICS, + }) + }) + + it('removes a product when toggled again', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + logic.actions.toggleSelectedProduct(ProductKey.SESSION_REPLAY) + }).toMatchValues({ + selectedProducts: [ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY], + }) + + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + }).toMatchValues({ + selectedProducts: [ProductKey.SESSION_REPLAY], + firstProductOnboarding: ProductKey.SESSION_REPLAY, + }) + }) + + it('sets first product when adding to empty list', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.FEATURE_FLAGS) + }).toMatchValues({ + selectedProducts: [ProductKey.FEATURE_FLAGS], + firstProductOnboarding: ProductKey.FEATURE_FLAGS, + }) + }) + + it('updates first product when it is removed', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + logic.actions.toggleSelectedProduct(ProductKey.SESSION_REPLAY) + logic.actions.setFirstProductOnboarding(ProductKey.PRODUCT_ANALYTICS) + }).toMatchValues({ + firstProductOnboarding: ProductKey.PRODUCT_ANALYTICS, + }) + + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + }).toMatchValues({ + selectedProducts: [ProductKey.SESSION_REPLAY], + firstProductOnboarding: ProductKey.SESSION_REPLAY, + }) + }) + }) + + describe('use case preselection', () => { + it('sets preselected products from use case', async () => { + await expectLogic(logic, () => { + logic.actions.setPreselectedProducts([ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY]) + }).toMatchValues({ + preSelectedProducts: [ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY], + selectedProducts: [ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY], + firstProductOnboarding: ProductKey.PRODUCT_ANALYTICS, + }) + }) + + it('sets first product to first in preselected list', async () => { + await expectLogic(logic, () => { + logic.actions.setPreselectedProducts([ProductKey.FEATURE_FLAGS, ProductKey.EXPERIMENTS]) + }).toMatchValues({ + firstProductOnboarding: ProductKey.FEATURE_FLAGS, + }) + }) + }) + + describe('use case onboarding enabled', () => { + it('returns true when use case is set and not pick_myself', async () => { + await expectLogic(logic, () => { + logic.actions.setUseCase('see_user_behavior') + }).toMatchValues({ + useCase: 'see_user_behavior', + isUseCaseOnboardingEnabled: true, + }) + }) + + it('returns false when use case is pick_myself', async () => { + await expectLogic(logic, () => { + logic.actions.setUseCase('pick_myself') + }).toMatchValues({ + useCase: 'pick_myself', + isUseCaseOnboardingEnabled: false, + }) + }) + + it('returns false when use case is null', async () => { + await expectLogic(logic).toMatchValues({ + useCase: null, + isUseCaseOnboardingEnabled: false, + }) + }) + }) + + describe('URL handling', () => { + it('sets use case from URL parameter', async () => { + router.actions.push('/products', { useCase: 'fix_issues' }) + + await expectLogic(logic).toMatchValues({ + useCase: 'fix_issues', + }) + }) + + it('preselects products based on use case URL parameter', async () => { + router.actions.push('/products', { useCase: 'see_user_behavior' }) + + await expectLogic(logic).toMatchValues({ + useCase: 'see_user_behavior', + preSelectedProducts: [ + ProductKey.PRODUCT_ANALYTICS, + ProductKey.SESSION_REPLAY, + ProductKey.WEB_ANALYTICS, + ], + selectedProducts: [ProductKey.PRODUCT_ANALYTICS, ProductKey.SESSION_REPLAY, ProductKey.WEB_ANALYTICS], + }) + }) + }) + + describe('starting onboarding', () => { + it('navigates to install step for most products', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.PRODUCT_ANALYTICS) + logic.actions.handleStartOnboarding() + }) + + expect(router.values.location.pathname).toBe( + `/project/${MOCK_TEAM_ID}/onboarding/${ProductKey.PRODUCT_ANALYTICS}` + ) + }) + + it('navigates to authorized domains step for web analytics', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.WEB_ANALYTICS) + logic.actions.handleStartOnboarding() + }) + + expect(router.values.location.pathname).toBe( + `/project/${MOCK_TEAM_ID}/onboarding/${ProductKey.WEB_ANALYTICS}` + ) + }) + + it('navigates to link data step for data warehouse', async () => { + await expectLogic(logic, () => { + logic.actions.toggleSelectedProduct(ProductKey.DATA_WAREHOUSE) + logic.actions.handleStartOnboarding() + }) + + expect(router.values.location.pathname).toBe( + `/project/${MOCK_TEAM_ID}/onboarding/${ProductKey.DATA_WAREHOUSE}` + ) + }) + + it('does nothing if no first product is set', async () => { + const initialPath = router.values.location.pathname + + await expectLogic(logic, () => { + logic.actions.handleStartOnboarding() + }) + + expect(router.values.location.pathname).toBe(initialPath) + }) + }) +}) diff --git a/frontend/src/scenes/products/productsLogic.ts b/frontend/src/scenes/products/productsLogic.ts index f6b71e2d9e..eb0b3764f1 100644 --- a/frontend/src/scenes/products/productsLogic.ts +++ b/frontend/src/scenes/products/productsLogic.ts @@ -1,9 +1,10 @@ -import { actions, connect, kea, listeners, path, reducers } from 'kea' -import { router } from 'kea-router' +import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { router, urlToAction } from 'kea-router' import { getRelativeNextPath } from 'lib/utils' import { ProductIntentContext } from 'lib/utils/product-intents' import { onboardingLogic } from 'scenes/onboarding/onboardingLogic' +import { getRecommendedProducts } from 'scenes/onboarding/productRecommendations' import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' @@ -20,6 +21,8 @@ export const productsLogic = kea([ toggleSelectedProduct: (productKey: ProductKey) => ({ productKey }), setFirstProductOnboarding: (productKey: ProductKey) => ({ productKey }), handleStartOnboarding: () => true, + setPreselectedProducts: (productKeys: ProductKey[]) => ({ productKeys }), + setUseCase: (useCase: string | null) => ({ useCase }), })), reducers({ selectedProducts: [ @@ -27,14 +30,34 @@ export const productsLogic = kea([ { toggleSelectedProduct: (state, { productKey }) => state.includes(productKey) ? state.filter((key) => key !== productKey) : [...state, productKey], + setPreselectedProducts: (_, { productKeys }) => productKeys, }, ], firstProductOnboarding: [ null as ProductKey | null, { setFirstProductOnboarding: (_, { productKey }) => productKey, + setPreselectedProducts: (_, { productKeys }) => productKeys[0] || null, }, ], + preSelectedProducts: [ + [] as ProductKey[], + { + setPreselectedProducts: (_, { productKeys }) => productKeys, + }, + ], + useCase: [ + null as string | null, + { + setUseCase: (_, { useCase }) => useCase, + }, + ], + }), + selectors({ + isUseCaseOnboardingEnabled: [ + (s) => [s.useCase], + (useCase: string | null): boolean => !!useCase && useCase !== 'pick_myself', + ], }), listeners(({ actions, values }) => ({ handleStartOnboarding: () => { @@ -83,4 +106,23 @@ export const productsLogic = kea([ } }, })), + urlToAction(({ actions }) => ({ + [urls.products()]: (_: any, searchParams: Record) => { + if (searchParams.useCase) { + actions.setUseCase(searchParams.useCase) + const recommendedProducts = getRecommendedProducts(searchParams.useCase) + if (recommendedProducts.length > 0) { + actions.setPreselectedProducts([...recommendedProducts]) + + // Track analytics when products are preselected based on use case + if (window.posthog) { + window.posthog.capture('onboarding_products_preselected', { + use_case: searchParams.useCase, + recommended_products: recommendedProducts, + }) + } + } + } + }, + })), ]) diff --git a/frontend/src/scenes/projectLogic.ts b/frontend/src/scenes/projectLogic.ts index 525017f3ac..60dc9f45c8 100644 --- a/frontend/src/scenes/projectLogic.ts +++ b/frontend/src/scenes/projectLogic.ts @@ -2,7 +2,9 @@ import { actions, afterMount, connect, kea, listeners, path, reducers, selectors import { loaders } from 'kea-loaders' import api, { ApiConfig } from 'lib/api' +import { FEATURE_FLAGS } from 'lib/constants' import { lemonToast } from 'lib/lemon-ui/LemonToast' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { identifierToHuman, isUserLoggedIn } from 'lib/utils' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { getAppContext } from 'lib/utils/getAppContext' @@ -29,6 +31,7 @@ export const projectLogic = kea([ organizationLogic, ['loadCurrentOrganization'], ], + values: [featureFlagLogic, ['featureFlags']], })), reducers({ projectBeingDeleted: [ @@ -114,7 +117,7 @@ export const projectLogic = kea([ selectors({ currentProjectId: [(s) => [s.currentProject], (currentProject) => currentProject?.id || null], }), - listeners(({ actions }) => ({ + listeners(({ actions, values }) => ({ loadCurrentProjectSuccess: ({ currentProject }) => { if (currentProject) { ApiConfig.setCurrentProjectId(currentProject.id) @@ -134,7 +137,9 @@ export const projectLogic = kea([ }, createProjectSuccess: ({ currentProject }) => { if (currentProject) { - actions.switchTeam(currentProject.id, urls.products()) + const useUseCaseSelection = values.featureFlags[FEATURE_FLAGS.ONBOARDING_USE_CASE_SELECTION] === true + const redirectUrl = useUseCaseSelection ? urls.useCaseSelection() : urls.products() + actions.switchTeam(currentProject.id, redirectUrl) } }, diff --git a/frontend/src/scenes/sceneLogic.tsx b/frontend/src/scenes/sceneLogic.tsx index c054ebbd54..6e61d30993 100644 --- a/frontend/src/scenes/sceneLogic.tsx +++ b/frontend/src/scenes/sceneLogic.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react' import api from 'lib/api' import { commandBarLogic } from 'lib/components/CommandBar/commandBarLogic' import { BarStatus } from 'lib/components/CommandBar/types' -import { TeamMembershipLevel } from 'lib/constants' +import { FEATURE_FLAGS, TeamMembershipLevel } from 'lib/constants' import { trackFileSystemLogView } from 'lib/hooks/useFileSystemLogView' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { Spinner } from 'lib/lemon-ui/Spinner' @@ -271,6 +271,7 @@ const productsNotDependingOnEventIngestion: ProductKey[] = [ProductKey.DATA_WARE const pathPrefixesOnboardingNotRequiredFor = [ urls.onboarding(''), + urls.useCaseSelection(), urls.products(), '/settings', urls.organizationBilling(), @@ -1122,13 +1123,22 @@ export const sceneLogic = kea([ ) && !teamLogic.values.currentTeam?.ingested_event ) { - console.warn('No onboarding completed, redirecting to /products') - const nextUrl = getRelativeNextPath(params.searchParams.next, location) ?? removeProjectIdIfPresent(location.pathname) - router.actions.replace(urls.products(), nextUrl ? { next: nextUrl } : undefined) + // Default to false (products page) if feature flags haven't loaded yet + const useUseCaseSelection = + values.featureFlags[FEATURE_FLAGS.ONBOARDING_USE_CASE_SELECTION] === true + + if (useUseCaseSelection) { + router.actions.replace( + urls.useCaseSelection(), + nextUrl ? { next: nextUrl } : undefined + ) + } else { + router.actions.replace(urls.products(), nextUrl ? { next: nextUrl } : undefined) + } return } diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index 946696f8b6..1516ba841a 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -99,6 +99,7 @@ export enum Scene { PipelineNodeNew = 'PipelineNodeNew', PreflightCheck = 'PreflightCheck', Products = 'Products', + UseCaseSelection = 'UseCaseSelection', ProjectCreateFirst = 'ProjectCreate', ProjectHomepage = 'ProjectHomepage', PropertyDefinition = 'PropertyDefinition', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 6b80b36e66..de2d0c9ffd 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -356,6 +356,7 @@ export const sceneConfigurations: Record = { }, [Scene.PreflightCheck]: { onlyUnauthenticated: true }, [Scene.Products]: { projectBased: true, name: 'Products', layout: 'plain' }, + [Scene.UseCaseSelection]: { projectBased: true, name: 'Use case selection', layout: 'plain' }, [Scene.ProjectCreateFirst]: { name: 'Project creation', organizationBased: true, @@ -709,6 +710,7 @@ export const routes: Record = { [urls.passwordReset()]: [Scene.PasswordReset, 'passwordReset'], [urls.passwordResetComplete(':uuid', ':token')]: [Scene.PasswordResetComplete, 'passwordResetComplete'], [urls.products()]: [Scene.Products, 'products'], + [urls.useCaseSelection()]: [Scene.UseCaseSelection, 'useCaseSelection'], [urls.onboarding(':productKey')]: [Scene.Onboarding, 'onboarding'], [urls.verifyEmail()]: [Scene.VerifyEmail, 'verifyEmail'], [urls.verifyEmail(':uuid')]: [Scene.VerifyEmail, 'verifyEmailWithUuid'], diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 11241e6053..855dfc0baa 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -105,6 +105,7 @@ export const urls = { `/verify_email${userUuid ? `/${userUuid}` : ''}${token ? `/${token}` : ''}`, inviteSignup: (id: string): string => `/signup/${id}`, products: (): string => '/products', + useCaseSelection: (): string => '/onboarding/use-case', onboarding: (productKey: string, stepKey?: OnboardingStepKey, sdk?: SDKKey): string => `/onboarding/${productKey}${stepKey ? '?step=' + stepKey : ''}${ sdk && stepKey ? '&sdk=' + sdk : sdk ? '?sdk=' + sdk : '' diff --git a/playwright/e2e/onboarding-use-case-selection.spec.ts b/playwright/e2e/onboarding-use-case-selection.spec.ts new file mode 100644 index 0000000000..4bc206a82e --- /dev/null +++ b/playwright/e2e/onboarding-use-case-selection.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '../utils/playwright-test-base' + +test.describe('Use Case Selection Onboarding', () => { + test.beforeEach(async ({ page, request }) => { + // Reset onboarding state + await request.patch('/api/projects/1/', { + data: { completed_snippet_onboarding: false }, + headers: { Authorization: 'Bearer e2e_demo_api_key' }, + }) + + // Enable the feature flag + await page.goto('/home') + await page.evaluate(() => { + window.posthog?.featureFlags?.override({ 'onboarding-use-case-selection': true }) + }) + }) + + test.afterAll(async ({ request }) => { + await request.patch('/api/projects/1/', { + data: { completed_snippet_onboarding: true }, + headers: { Authorization: 'Bearer e2e_demo_api_key' }, + }) + }) + + test('displays use case selection page', async ({ page }) => { + await page.goto('/onboarding/use-case') + + // Check for main heading + await expect(page.locator('h1')).toContainText('What do you want to do with PostHog?') + + // Check for specific use cases + await expect(page.locator('text=Understand how users behave')).toBeVisible() + await expect(page.locator('text=Find and fix issues')).toBeVisible() + await expect(page.locator('text=Launch features with confidence')).toBeVisible() + await expect(page.locator('text=Collect user feedback')).toBeVisible() + await expect(page.locator('text=Monitor AI applications')).toBeVisible() + }) + + test('selects a use case and navigates to products page', async ({ page }) => { + await page.goto('/onboarding/use-case') + + // Click on "Understand how users behave" use case + await page.locator('text=Understand how users behave').click() + + // Should navigate to products page with useCase param + await expect(page).toHaveURL(/\/products\?useCase=see_user_behavior/) + + // Should show recommended products pre-selected + await expect(page.locator('[data-attr="product_analytics-onboarding-card"]')).toHaveClass(/border-accent/) + await expect(page.locator('[data-attr="session_replay-onboarding-card"]')).toHaveClass(/border-accent/) + }) + + test('products page shows back button with use case selection', async ({ page }) => { + await page.goto('/products?useCase=fix_issues') + + // Back button should be visible + await expect(page.locator('text=Go back to change my goal')).toBeVisible() + + // Click back button + await page.locator('text=Go back to change my goal').click() + + // Should navigate back to use case selection + await expect(page).toHaveURL(/\/onboarding\/use-case/) + }) +})