Skip to content

Commit 5a7d485

Browse files
committed
feat(surveys): create survey cross-sell opportunity logic
1 parent e223d81 commit 5a7d485

File tree

7 files changed

+188
-0
lines changed

7 files changed

+188
-0
lines changed

frontend/src/lib/utils/eventUsageLogic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,7 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
13531353
questions_length: survey.questions.length,
13541354
question_types: survey.questions.map((question) => question.type),
13551355
is_duplicate: isDuplicate ?? false,
1356+
linked_insight_id: survey.linked_insight_id,
13561357
events_count: survey.conditions?.events?.values.length,
13571358
recurring_survey_iteration_count: survey.iteration_count == undefined ? 0 : survey.iteration_count,
13581359
recurring_survey_iteration_interval:

frontend/src/queries/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27027,6 +27027,9 @@
2702727027
"linked_flag_id": {
2702827028
"type": "number"
2702927029
},
27030+
"linked_insight_id": {
27031+
"type": "number"
27032+
},
2703027033
"name": {
2703127034
"type": "string"
2703227035
},

frontend/src/queries/schema/schema-surveys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface SurveyCreationSchema {
1818
description: string
1919
type: SurveyType
2020
linked_flag_id?: number
21+
linked_insight_id?: number
2122
questions: SurveyQuestionSchema[]
2223
should_launch?: boolean
2324
conditions?: SurveyDisplayConditionsSchema
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { router } from 'kea-router'
2+
import posthog from 'posthog-js'
3+
import { useEffect } from 'react'
4+
5+
import { IconMessage } from '@posthog/icons'
6+
import { LemonButton } from '@posthog/lemon-ui'
7+
8+
import { formatPercentage } from 'lib/utils'
9+
import { ProductIntentContext, addProductIntent } from 'lib/utils/product-intents'
10+
import { useMaxTool } from 'scenes/max/useMaxTool'
11+
import { urls } from 'scenes/urls'
12+
13+
import { ProductKey, QueryBasedInsightModel } from '~/types'
14+
15+
import { SURVEY_CREATED_SOURCE } from '../constants'
16+
import { captureMaxAISurveyCreationException } from '../utils'
17+
import { extractFunnelContext } from '../utils/opportunityDetection'
18+
19+
export interface SurveyOpportunityButtonProps {
20+
insight: QueryBasedInsightModel
21+
disableAutoPromptSubmit?: boolean
22+
}
23+
24+
export function SurveyOpportunityButton({
25+
insight,
26+
disableAutoPromptSubmit,
27+
}: SurveyOpportunityButtonProps): JSX.Element | null {
28+
const funnelContext = extractFunnelContext(insight)
29+
const initialMaxPrompt = funnelContext
30+
? `${disableAutoPromptSubmit ? '' : '!'}Create a survey to help me identify and fix the root ` +
31+
`cause for ${formatPercentage(funnelContext.conversionRate * 100)} conversion in my ` +
32+
`"${funnelContext.insightName}" funnel (\`${insight.id}\`). Read this insight to understand the ` +
33+
`conversion goal, and suggest the best display / targeting strategies.`
34+
: ''
35+
36+
useEffect(() => {
37+
posthog.capture('survey opportunity displayed', {
38+
linked_insight_id: insight.id,
39+
conversionRate: funnelContext?.conversionRate, // oxlint-disable-line react-hooks/exhaustive-deps
40+
})
41+
}, [insight.id])
42+
43+
const { openMax } = useMaxTool({
44+
identifier: 'create_survey',
45+
active: true,
46+
initialMaxPrompt,
47+
context: {
48+
insight_id: insight.id,
49+
...funnelContext,
50+
},
51+
callback: (toolOutput: { survey_id?: string; survey_name?: string; error?: string }) => {
52+
addProductIntent({
53+
product_type: ProductKey.SURVEYS,
54+
intent_context: ProductIntentContext.SURVEY_CREATED,
55+
metadata: {
56+
survey_id: toolOutput.survey_id,
57+
source: SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL,
58+
created_successfully: !toolOutput?.error,
59+
},
60+
})
61+
62+
if (toolOutput?.error || !toolOutput?.survey_id) {
63+
return captureMaxAISurveyCreationException(toolOutput.error, SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL)
64+
}
65+
66+
router.actions.push(urls.survey(toolOutput.survey_id))
67+
},
68+
})
69+
70+
const handleClick = (): void => {
71+
posthog.capture('survey opportunity clicked', {
72+
linked_insight_id: insight.id,
73+
conversionRate: funnelContext?.conversionRate,
74+
})
75+
openMax?.()
76+
}
77+
78+
return (
79+
<LemonButton size="xsmall" type="primary" sideIcon={<IconMessage />} onClick={handleClick}>
80+
Ask users why
81+
</LemonButton>
82+
)
83+
}

frontend/src/scenes/surveys/constants.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ export enum SURVEY_CREATED_SOURCE {
630630
SURVEY_FORM = 'survey_form',
631631
SURVEY_EMPTY_STATE = 'survey_empty_state',
632632
EXPERIMENTS = 'experiments',
633+
INSIGHT_CROSS_SELL = 'insight_cross_sell',
633634
}
634635

635636
export enum SURVEY_EMPTY_STATE_EXPERIMENT_VARIANT {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { FunnelsQuery, InsightVizNode } from '~/queries/schema/schema-general'
2+
import { DashboardTile, QueryBasedInsightModel } from '~/types'
3+
4+
export interface FunnelContext {
5+
insightName: string
6+
conversionRate: number
7+
steps: string[]
8+
}
9+
10+
/**
11+
* Given a list of dashboard tiles, find the funnel with the best
12+
* opportunity to create a survey. See {@link getBestSurveyOpportunityFromDashboard}
13+
* for logic details
14+
*
15+
* @param tiles list of dashboard tiles
16+
* @param linkedInsightIds list of surveys with linked insights
17+
* @returns
18+
*/
19+
export function getBestSurveyOpportunityFunnel(
20+
tiles: DashboardTile<QueryBasedInsightModel>[],
21+
linkedInsightIds: Set<number> = new Set()
22+
): DashboardTile<QueryBasedInsightModel> | null {
23+
return getBestSurveyOpportunityFromDashboard(
24+
tiles.filter((tile) => isFunnelInsight(tile.insight?.query)),
25+
linkedInsightIds
26+
)
27+
}
28+
29+
/**
30+
* Get some useful "context" data from a funnel insight
31+
*
32+
* @param insight funnel insight
33+
* @returns funnel "context" object with name, conversion rate (0-1), list of step names
34+
*/
35+
export function extractFunnelContext(insight: Partial<QueryBasedInsightModel>): FunnelContext | null {
36+
if (!isFunnelInsight(insight.query)) {
37+
return null
38+
}
39+
40+
const result = insight.result
41+
if (!result || !Array.isArray(result) || result.length === 0) {
42+
return null
43+
}
44+
45+
const conversionRate = conversionRateFromInsight(result)
46+
const steps = result.map((step: any) => step.name || 'Unknown step')
47+
48+
return {
49+
insightName: insight.name || 'Funnel',
50+
conversionRate,
51+
steps,
52+
}
53+
}
54+
55+
/**
56+
* Given a list of dashboard tiles (insights), find the best opportunity to
57+
* create a survey.
58+
*
59+
* Logic:
60+
* - Filter insights with existing linked surveys
61+
* - Calculate overall conversion rate
62+
* - Filter conversion rate < 50%
63+
* - Return lowest conversion rate funnel
64+
*
65+
* @param tiles list of dashboard tiles
66+
* @param linkedInsightIds list of surveys with linked insights
67+
* @returns
68+
*/
69+
function getBestSurveyOpportunityFromDashboard(
70+
tiles: DashboardTile<QueryBasedInsightModel>[],
71+
linkedInsightIds: Set<number> = new Set()
72+
): DashboardTile<QueryBasedInsightModel> | null {
73+
const funnelTiles = tiles
74+
.filter((tile) => tile.insight?.id && !linkedInsightIds.has(tile.insight.id))
75+
.map((tile) => {
76+
const result = tile.insight?.result
77+
if (!result || !Array.isArray(result) || result.length === 0) {
78+
return { tile, conversionRate: 1 }
79+
}
80+
81+
const conversionRate = conversionRateFromInsight(result)
82+
return { tile, conversionRate }
83+
})
84+
.filter(({ conversionRate }) => conversionRate < 0.5)
85+
.sort((a, b) => a.conversionRate - b.conversionRate)
86+
87+
return funnelTiles[0]?.tile || null
88+
}
89+
90+
function conversionRateFromInsight(result: QueryBasedInsightModel['result']): number {
91+
const first = result[0]?.count || 1
92+
const last = result[result.length - 1]?.count || 0
93+
return last / first
94+
}
95+
96+
function isFunnelInsight(query: any): query is InsightVizNode<FunnelsQuery> {
97+
return query?.kind === 'InsightVizNode' && query?.source?.kind === 'FunnelsQuery'
98+
}

frontend/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3253,6 +3253,7 @@ export interface Survey extends WithAccessControl {
32533253
linked_flag: FeatureFlagBasicType | null
32543254
targeting_flag: FeatureFlagBasicType | null
32553255
targeting_flag_filters?: FeatureFlagFilters
3256+
linked_insight_id?: number | null
32563257
conditions: SurveyDisplayConditions | null
32573258
appearance: SurveyAppearance | null
32583259
questions: (BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion)[]

0 commit comments

Comments
 (0)