feat: add use case driven onboarding flow (#41147)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Rafael Audibert <32079912+rafaeelaudibert@users.noreply.github.com>
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -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<ProjectNoticeVariant, ProjectNoticeBlueprint> = {
|
||||
demo_project: {
|
||||
@@ -81,7 +85,10 @@ export function ProjectNotice({ className }: { className?: string }): JSX.Elemen
|
||||
{' '}
|
||||
When you're ready, head on over to the{' '}
|
||||
<Link
|
||||
to={urls.project(altTeamForIngestion.id, urls.products())}
|
||||
to={urls.project(
|
||||
altTeamForIngestion.id,
|
||||
useUseCaseSelection ? urls.useCaseSelection() : urls.products()
|
||||
)}
|
||||
data-attr="demo-project-alt-team-ingestion_link"
|
||||
>
|
||||
onboarding wizard
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -599,6 +599,10 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
|
||||
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<eventUsageLogicType>([
|
||||
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,
|
||||
|
||||
@@ -73,6 +73,7 @@ export const appScenes: Record<Scene | string, () => 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'),
|
||||
|
||||
88
frontend/src/scenes/onboarding/productRecommendations.ts
Normal file
@@ -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<UseCaseDefinition> = [
|
||||
{
|
||||
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 || ''
|
||||
}
|
||||
@@ -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<typeof meta>
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-var(--scene-layout-header-height)-var(--scene-padding))] p-4 bg-primary">
|
||||
<div className="max-w-2xl w-full">
|
||||
<h1 className="text-4xl font-bold text-center mb-2">What do you want to do with PostHog?</h1>
|
||||
<p className="text-center text-muted mb-8">Select your primary goal to get started:</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{USE_CASE_OPTIONS.map((useCase) => (
|
||||
<LemonCard
|
||||
key={useCase.key}
|
||||
className="cursor-pointer hover:border-primary transition-colors hover:transform-none"
|
||||
onClick={() => selectUseCase(useCase.key)}
|
||||
hoverEffect
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl flex-shrink-0">
|
||||
{getProductIcon(useCase.iconColor, useCase.iconKey, 'text-3xl')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">{useCase.title}</h3>
|
||||
<p className="text-muted text-sm mb-0">{useCase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</LemonCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full mt-6">
|
||||
{hasIngestedEvent ? (
|
||||
<LemonButton status="alt" onClick={() => skipOnboarding()}>
|
||||
Skip onboarding
|
||||
</LemonButton>
|
||||
) : (
|
||||
<div /> // Spacer to keep "pick myself" on the right
|
||||
)}
|
||||
|
||||
<button
|
||||
className="text-muted hover:text-default text-sm"
|
||||
onClick={() => selectUseCase('pick_myself')}
|
||||
>
|
||||
I want to pick products myself →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof useCaseSelectionLogic.build>
|
||||
|
||||
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,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<useCaseSelectionLogicType>([
|
||||
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 })
|
||||
},
|
||||
})),
|
||||
])
|
||||
@@ -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<organizationLogicType>([
|
||||
deleteOrganizationSuccess: ({ redirectPath }: { redirectPath?: string }) => ({ redirectPath }),
|
||||
deleteOrganizationFailure: true,
|
||||
}),
|
||||
connect({
|
||||
values: [featureFlagLogic, ['featureFlags']],
|
||||
}),
|
||||
connect([userLogic]),
|
||||
reducers({
|
||||
organizationBeingDeleted: [
|
||||
@@ -137,7 +141,7 @@ export const organizationLogic = kea<organizationLogicType>([
|
||||
},
|
||||
],
|
||||
}),
|
||||
listeners(({ actions }) => ({
|
||||
listeners(({ actions, values }) => ({
|
||||
loadCurrentOrganizationSuccess: ({ currentOrganization }) => {
|
||||
if (currentOrganization) {
|
||||
ApiConfig.setCurrentOrganizationId(currentOrganization.id)
|
||||
@@ -145,7 +149,8 @@ export const organizationLogic = kea<organizationLogicType>([
|
||||
},
|
||||
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!')
|
||||
|
||||
@@ -32,6 +32,7 @@ const meta: Meta = {
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 <Icon className={className} color={color} />
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip
|
||||
title={
|
||||
const description = product.description || ''
|
||||
|
||||
const shouldShowTooltip = (!showDescription && product.description) || onboardingCompleted
|
||||
|
||||
const tooltipContent = (
|
||||
<>
|
||||
{!showDescription && product.description && (
|
||||
<>
|
||||
{product.description}
|
||||
<br />
|
||||
{onboardingCompleted && <em>You've already set up this app. Click to return to its page.</em>}
|
||||
</>
|
||||
}
|
||||
)}
|
||||
{onboardingCompleted && <em>You've already set up this app. Click to return to its page.</em>}
|
||||
</>
|
||||
)
|
||||
|
||||
const card = (
|
||||
<LemonCard
|
||||
data-attr={`${productKey}-onboarding-card`}
|
||||
className={clsx(
|
||||
'flex cursor-pointer',
|
||||
vertical ? 'flex-col justify-center' : 'items-center hover:transform-none',
|
||||
className
|
||||
)}
|
||||
key={productKey}
|
||||
onClick={onClick}
|
||||
focused={selected}
|
||||
hoverEffect={!vertical}
|
||||
>
|
||||
<LemonCard
|
||||
data-attr={`${productKey}-onboarding-card`}
|
||||
className={clsx(
|
||||
'flex justify-center cursor-pointer',
|
||||
vertical ? 'flex-col' : 'items-center',
|
||||
className
|
||||
)}
|
||||
key={productKey}
|
||||
onClick={onClick}
|
||||
focused={selected}
|
||||
>
|
||||
{onboardingCompleted && (
|
||||
<div
|
||||
className="relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
router.actions.push(getProductUri(productKey as ProductKey))
|
||||
}}
|
||||
data-attr={`return-to-${productKey}`}
|
||||
>
|
||||
<IconCheckCircle className="absolute top-0 right-0" color="green" />
|
||||
</div>
|
||||
)}
|
||||
{vertical ? (
|
||||
// Vertical layout
|
||||
<div className="flex flex-col gap-2 p-4 select-none">
|
||||
<div className="flex justify-center">
|
||||
{getProductIcon(product.iconColor, product.icon, 'text-2xl')}
|
||||
</div>
|
||||
<div className="font-bold text-center text-md">{product.name}</div>
|
||||
</div>
|
||||
</LemonCard>
|
||||
</Tooltip>
|
||||
) : (
|
||||
// Horizontal layout with description
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-3xl flex-shrink-0">
|
||||
{getProductIcon(product.iconColor, product.icon, 'text-3xl')}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">{product.name}</h3>
|
||||
{showDescription && description && <p className="text-muted text-sm mb-0">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</LemonCard>
|
||||
)
|
||||
|
||||
return shouldShowTooltip ? <Tooltip title={tooltipContent}>{card}</Tooltip> : 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 (
|
||||
<div className="flex flex-col flex-1 w-full min-h-full p-4 items-center justify-center bg-primary overflow-x-hidden">
|
||||
<>
|
||||
{/* Back button at the top */}
|
||||
{isUseCaseOnboardingEnabled && (
|
||||
<div className="w-full max-w-[800px] mb-4">
|
||||
<button
|
||||
className="text-muted hover:text-default text-sm flex items-center gap-1 cursor-pointer"
|
||||
onClick={() => router.actions.push(urls.useCaseSelection())}
|
||||
>
|
||||
← Go back to change my goal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col justify-center flex-grow items-center">
|
||||
<div className="mb-2">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-center text-4xl">Which apps would you like to use?</h2>
|
||||
<p className="text-center">
|
||||
Don't worry – you can pick more than one! Please select all that apply.
|
||||
<p className="text-center text-muted">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse sm:flex-col gap-6 md:gap-12 justify-center items-center w-full">
|
||||
<div className="flex flex-row flex-wrap gap-4 justify-center max-w-[680px]">
|
||||
{Object.keys(availableOnboardingProducts).map((productKey) => (
|
||||
<SelectableProductCard
|
||||
product={
|
||||
availableOnboardingProducts[
|
||||
productKey as keyof typeof availableOnboardingProducts
|
||||
]
|
||||
}
|
||||
key={productKey}
|
||||
productKey={productKey}
|
||||
onClick={() => {
|
||||
toggleSelectedProduct(productKey as ProductKey)
|
||||
}}
|
||||
className="w-[160px]"
|
||||
selected={selectedProducts.includes(productKey as ProductKey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col-reverse sm:flex-row gap-4 items-center justify-center w-full',
|
||||
hasIngestedEvent && 'sm:justify-between sm:px-4'
|
||||
)}
|
||||
>
|
||||
{hasIngestedEvent && (
|
||||
<LemonButton
|
||||
status="alt"
|
||||
onClick={() => {
|
||||
skipOnboarding()
|
||||
}}
|
||||
>
|
||||
Skip onboarding
|
||||
</LemonButton>
|
||||
)}
|
||||
{selectedProducts.length > 1 ? (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<LemonLabel>Start first with</LemonLabel>
|
||||
<LemonSelect
|
||||
value={firstProductOnboarding}
|
||||
options={selectedProducts.map((productKey) => ({
|
||||
label:
|
||||
availableOnboardingProducts[
|
||||
productKey as keyof typeof availableOnboardingProducts
|
||||
]?.name ?? '',
|
||||
value: productKey,
|
||||
}))}
|
||||
onChange={(value) => value && setFirstProductOnboarding(value)}
|
||||
placeholder="Select a product"
|
||||
className="bg-surface-primary"
|
||||
<div className="flex flex-col-reverse sm:flex-col gap-6 md:gap-12 justify-center items-center w-full">
|
||||
{isUseCaseOnboardingEnabled ? (
|
||||
// NEW LAYOUT: Horizontal cards with recommendations (when use case onboarding is enabled)
|
||||
<div className="max-w-[800px] w-full flex flex-col">
|
||||
{/* Recommended products - always shown if we have them */}
|
||||
{availablePreSelectedProducts.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
{availablePreSelectedProducts.map((productKey) => (
|
||||
<SelectableProductCard
|
||||
key={productKey}
|
||||
product={availableOnboardingProducts[productKey]}
|
||||
productKey={productKey}
|
||||
onClick={() => toggleSelectedProduct(productKey)}
|
||||
selected={selectedProducts.includes(productKey)}
|
||||
orientation="horizontal"
|
||||
showDescription={true}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other products section - always rendered to avoid layout shift */}
|
||||
{availablePreSelectedProducts.length > 0 && otherProducts.length > 0 && (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newState = !showAllProducts
|
||||
setShowAllProducts(newState)
|
||||
if (window.posthog && newState) {
|
||||
window.posthog.capture('onboarding_show_all_products_clicked', {
|
||||
use_case: useCase,
|
||||
recommended_count: availablePreSelectedProducts.length,
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="text-muted hover:text-default text-sm mb-2 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
{showAllProducts ? (
|
||||
<>
|
||||
<IconChevronDown className="rotate-180 text-xs" /> Hide other apps
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show all apps ({otherProducts.length} more){' '}
|
||||
<IconChevronDown className="text-xs" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Products with smooth height transition */}
|
||||
<div
|
||||
className="w-full overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
maxHeight: showAllProducts ? '2000px' : '0px',
|
||||
opacity: showAllProducts ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 mb-6">
|
||||
{otherProducts.map((productKey) => (
|
||||
<SelectableProductCard
|
||||
key={productKey}
|
||||
product={availableOnboardingProducts[productKey]}
|
||||
productKey={productKey}
|
||||
onClick={() => toggleSelectedProduct(productKey)}
|
||||
selected={selectedProducts.includes(productKey)}
|
||||
orientation="horizontal"
|
||||
showDescription={true}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// OLD LAYOUT: Flex wrap layout (when use case onboarding is disabled)
|
||||
<div className="flex flex-row flex-wrap gap-4 justify-center max-w-[680px]">
|
||||
{AVAILABLE_ONBOARDING_PRODUCT_KEYS.map((productKey) => (
|
||||
<SelectableProductCard
|
||||
key={productKey}
|
||||
product={availableOnboardingProducts[productKey]}
|
||||
productKey={productKey}
|
||||
onClick={() => toggleSelectedProduct(productKey)}
|
||||
selected={selectedProducts.includes(productKey)}
|
||||
orientation="vertical"
|
||||
showDescription={false}
|
||||
className="w-[160px]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col-reverse sm:flex-row gap-4 items-center justify-center w-full',
|
||||
hasIngestedEvent && 'sm:justify-between sm:px-4'
|
||||
)}
|
||||
>
|
||||
{hasIngestedEvent && (
|
||||
<LemonButton
|
||||
status="alt"
|
||||
onClick={() => {
|
||||
skipOnboarding()
|
||||
}}
|
||||
>
|
||||
Skip onboarding
|
||||
</LemonButton>
|
||||
)}
|
||||
{selectedProducts.length > 1 ? (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<LemonLabel>Start with</LemonLabel>
|
||||
<LemonSelect
|
||||
value={firstProductOnboarding}
|
||||
options={selectedProducts.map((productKey) => ({
|
||||
label: isAvailableOnboardingProductKey(productKey)
|
||||
? availableOnboardingProducts[productKey].name
|
||||
: productKey,
|
||||
value: productKey,
|
||||
}))}
|
||||
onChange={(value) => value && setFirstProductOnboarding(value)}
|
||||
placeholder="Select a product"
|
||||
className="bg-surface-primary"
|
||||
/>
|
||||
<LemonButton
|
||||
sideIcon={<IconArrowRight />}
|
||||
onClick={handleStartOnboarding}
|
||||
type="primary"
|
||||
status="alt"
|
||||
data-attr="onboarding-continue"
|
||||
>
|
||||
Go
|
||||
</LemonButton>
|
||||
</div>
|
||||
) : (
|
||||
<LemonButton
|
||||
sideIcon={<IconArrowRight />}
|
||||
onClick={handleStartOnboarding}
|
||||
type="primary"
|
||||
status="alt"
|
||||
onClick={handleStartOnboarding}
|
||||
data-attr="onboarding-continue"
|
||||
sideIcon={<IconArrowRight />}
|
||||
disabledReason={
|
||||
selectedProducts.length === 0 ? 'Select a product to start with' : undefined
|
||||
}
|
||||
>
|
||||
Go
|
||||
Get started
|
||||
</LemonButton>
|
||||
</div>
|
||||
) : (
|
||||
<LemonButton
|
||||
type="primary"
|
||||
status="alt"
|
||||
onClick={handleStartOnboarding}
|
||||
data-attr="onboarding-continue"
|
||||
sideIcon={<IconArrowRight />}
|
||||
disabledReason={
|
||||
selectedProducts.length === 0 ? 'Select a product to start with' : undefined
|
||||
}
|
||||
>
|
||||
Get started
|
||||
</LemonButton>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
194
frontend/src/scenes/products/productsLogic.test.ts
Normal file
@@ -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<typeof productsLogic.build>
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<productsLogicType>([
|
||||
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<productsLogicType>([
|
||||
{
|
||||
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<productsLogicType>([
|
||||
}
|
||||
},
|
||||
})),
|
||||
urlToAction(({ actions }) => ({
|
||||
[urls.products()]: (_: any, searchParams: Record<string, any>) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})),
|
||||
])
|
||||
|
||||
@@ -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<projectLogicType>([
|
||||
organizationLogic,
|
||||
['loadCurrentOrganization'],
|
||||
],
|
||||
values: [featureFlagLogic, ['featureFlags']],
|
||||
})),
|
||||
reducers({
|
||||
projectBeingDeleted: [
|
||||
@@ -114,7 +117,7 @@ export const projectLogic = kea<projectLogicType>([
|
||||
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<projectLogicType>([
|
||||
},
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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<sceneLogicType>([
|
||||
) &&
|
||||
!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
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ export enum Scene {
|
||||
PipelineNodeNew = 'PipelineNodeNew',
|
||||
PreflightCheck = 'PreflightCheck',
|
||||
Products = 'Products',
|
||||
UseCaseSelection = 'UseCaseSelection',
|
||||
ProjectCreateFirst = 'ProjectCreate',
|
||||
ProjectHomepage = 'ProjectHomepage',
|
||||
PropertyDefinition = 'PropertyDefinition',
|
||||
|
||||
@@ -356,6 +356,7 @@ export const sceneConfigurations: Record<Scene | string, SceneConfig> = {
|
||||
},
|
||||
[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<string, [Scene | string, string]> = {
|
||||
[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'],
|
||||
|
||||
@@ -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 : ''
|
||||
|
||||
65
playwright/e2e/onboarding-use-case-selection.spec.ts
Normal file
@@ -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/)
|
||||
})
|
||||
})
|
||||