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>
This commit is contained in:
Matt Brooker
2025-11-13 14:31:11 -05:00
committed by GitHub
parent dfb78509e4
commit de43b1efa4
36 changed files with 956 additions and 118 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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'),

View 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 || ''
}

View File

@@ -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,
},
},
},
}

View File

@@ -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>
)
}

View File

@@ -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,
})
)
}
})
})
})

View File

@@ -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 })
},
})),
])

View File

@@ -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!')

View File

@@ -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,
},
},
},
}

View File

@@ -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 &ndash; 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>

View 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)
})
})
})

View File

@@ -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,
})
}
}
}
},
})),
])

View File

@@ -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)
}
},

View File

@@ -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
}

View File

@@ -99,6 +99,7 @@ export enum Scene {
PipelineNodeNew = 'PipelineNodeNew',
PreflightCheck = 'PreflightCheck',
Products = 'Products',
UseCaseSelection = 'UseCaseSelection',
ProjectCreateFirst = 'ProjectCreate',
ProjectHomepage = 'ProjectHomepage',
PropertyDefinition = 'PropertyDefinition',

View File

@@ -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'],

View File

@@ -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 : ''

View 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/)
})
})