diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index fb82c95509040..f86a0d8544c7f 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -1353,6 +1353,7 @@ export const eventUsageLogic = kea([ questions_length: survey.questions.length, question_types: survey.questions.map((question) => question.type), is_duplicate: isDuplicate ?? false, + linked_insight_id: survey.linked_insight_id, events_count: survey.conditions?.events?.values.length, recurring_survey_iteration_count: survey.iteration_count == undefined ? 0 : survey.iteration_count, recurring_survey_iteration_interval: diff --git a/frontend/src/scenes/surveys/components/SurveyOpportunityButton.tsx b/frontend/src/scenes/surveys/components/SurveyOpportunityButton.tsx new file mode 100644 index 0000000000000..c72163a9d4300 --- /dev/null +++ b/frontend/src/scenes/surveys/components/SurveyOpportunityButton.tsx @@ -0,0 +1,83 @@ +import { router } from 'kea-router' +import posthog from 'posthog-js' +import { useEffect } from 'react' + +import { IconMessage } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { formatPercentage } from 'lib/utils' +import { ProductIntentContext, addProductIntent } from 'lib/utils/product-intents' +import { useMaxTool } from 'scenes/max/useMaxTool' +import { urls } from 'scenes/urls' + +import { ProductKey, QueryBasedInsightModel } from '~/types' + +import { SURVEY_CREATED_SOURCE } from '../constants' +import { captureMaxAISurveyCreationException } from '../utils' +import { extractFunnelContext } from '../utils/opportunityDetection' + +export interface SurveyOpportunityButtonProps { + insight: QueryBasedInsightModel + disableAutoPromptSubmit?: boolean +} + +export function SurveyOpportunityButton({ + insight, + disableAutoPromptSubmit, +}: SurveyOpportunityButtonProps): JSX.Element | null { + const funnelContext = extractFunnelContext(insight) + const initialMaxPrompt = funnelContext + ? `${disableAutoPromptSubmit ? '' : '!'}Create a survey to help me identify and fix the root ` + + `cause for ${formatPercentage(funnelContext.conversionRate * 100)} conversion in my ` + + `"${funnelContext.insightName}" funnel (\`${insight.id}\`). Read this insight to understand the ` + + `conversion goal, and suggest the best display / targeting strategies.` + : '' + + useEffect(() => { + posthog.capture('survey opportunity displayed', { + linked_insight_id: insight.id, + conversionRate: funnelContext?.conversionRate, // oxlint-disable-line react-hooks/exhaustive-deps + }) + }, [insight.id]) + + const { openMax } = useMaxTool({ + identifier: 'create_survey', + active: true, + initialMaxPrompt, + context: { + insight_id: insight.id, + ...funnelContext, + }, + callback: (toolOutput: { survey_id?: string; survey_name?: string; error?: string }) => { + addProductIntent({ + product_type: ProductKey.SURVEYS, + intent_context: ProductIntentContext.SURVEY_CREATED, + metadata: { + survey_id: toolOutput.survey_id, + source: SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL, + created_successfully: !toolOutput?.error, + }, + }) + + if (toolOutput?.error || !toolOutput?.survey_id) { + return captureMaxAISurveyCreationException(toolOutput.error, SURVEY_CREATED_SOURCE.INSIGHT_CROSS_SELL) + } + + router.actions.push(urls.survey(toolOutput.survey_id)) + }, + }) + + const handleClick = (): void => { + posthog.capture('survey opportunity clicked', { + linked_insight_id: insight.id, + conversionRate: funnelContext?.conversionRate, + }) + openMax?.() + } + + return ( + } onClick={handleClick}> + Ask users why + + ) +} diff --git a/frontend/src/scenes/surveys/constants.tsx b/frontend/src/scenes/surveys/constants.tsx index fa48092b573b7..e4ab40faf93d5 100644 --- a/frontend/src/scenes/surveys/constants.tsx +++ b/frontend/src/scenes/surveys/constants.tsx @@ -630,6 +630,7 @@ export enum SURVEY_CREATED_SOURCE { SURVEY_FORM = 'survey_form', SURVEY_EMPTY_STATE = 'survey_empty_state', EXPERIMENTS = 'experiments', + INSIGHT_CROSS_SELL = 'insight_cross_sell', } export enum SURVEY_EMPTY_STATE_EXPERIMENT_VARIANT { diff --git a/frontend/src/scenes/surveys/utils/opportunityDetection.ts b/frontend/src/scenes/surveys/utils/opportunityDetection.ts new file mode 100644 index 0000000000000..05fdbb4345f21 --- /dev/null +++ b/frontend/src/scenes/surveys/utils/opportunityDetection.ts @@ -0,0 +1,98 @@ +import { FunnelsQuery, InsightVizNode } from '~/queries/schema/schema-general' +import { DashboardTile, QueryBasedInsightModel } from '~/types' + +export interface FunnelContext { + insightName: string + conversionRate: number + steps: string[] +} + +/** + * Given a list of dashboard tiles, find the funnel with the best + * opportunity to create a survey. See {@link getBestSurveyOpportunityFromDashboard} + * for logic details + * + * @param tiles list of dashboard tiles + * @param linkedInsightIds list of surveys with linked insights + * @returns + */ +export function getBestSurveyOpportunityFunnel( + tiles: DashboardTile[], + linkedInsightIds: Set = new Set() +): DashboardTile | null { + return getBestSurveyOpportunityFromDashboard( + tiles.filter((tile) => isFunnelInsight(tile.insight?.query)), + linkedInsightIds + ) +} + +/** + * Get some useful "context" data from a funnel insight + * + * @param insight funnel insight + * @returns funnel "context" object with name, conversion rate (0-1), list of step names + */ +export function extractFunnelContext(insight: Partial): FunnelContext | null { + if (!isFunnelInsight(insight.query)) { + return null + } + + const result = insight.result + if (!result || !Array.isArray(result) || result.length === 0) { + return null + } + + const conversionRate = conversionRateFromInsight(result) + const steps = result.map((step: any) => step.name || 'Unknown step') + + return { + insightName: insight.name || 'Funnel', + conversionRate, + steps, + } +} + +/** + * Given a list of dashboard tiles (insights), find the best opportunity to + * create a survey. + * + * Logic: + * - Filter insights with existing linked surveys + * - Calculate overall conversion rate + * - Filter conversion rate < 50% + * - Return lowest conversion rate funnel + * + * @param tiles list of dashboard tiles + * @param linkedInsightIds list of surveys with linked insights + * @returns + */ +function getBestSurveyOpportunityFromDashboard( + tiles: DashboardTile[], + linkedInsightIds: Set = new Set() +): DashboardTile | null { + const funnelTiles = tiles + .filter((tile) => tile.insight?.id && !linkedInsightIds.has(tile.insight.id)) + .map((tile) => { + const result = tile.insight?.result + if (!result || !Array.isArray(result) || result.length === 0) { + return { tile, conversionRate: 1 } + } + + const conversionRate = conversionRateFromInsight(result) + return { tile, conversionRate } + }) + .filter(({ conversionRate }) => conversionRate < 0.5) + .sort((a, b) => a.conversionRate - b.conversionRate) + + return funnelTiles[0]?.tile || null +} + +function conversionRateFromInsight(result: QueryBasedInsightModel['result']): number { + const first = result[0]?.count || 1 + const last = result[result.length - 1]?.count || 0 + return last / first +} + +function isFunnelInsight(query: any): query is InsightVizNode { + return query?.kind === 'InsightVizNode' && query?.source?.kind === 'FunnelsQuery' +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 22f084ad53cea..5f13c392223b9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -3253,6 +3253,7 @@ export interface Survey extends WithAccessControl { linked_flag: FeatureFlagBasicType | null targeting_flag: FeatureFlagBasicType | null targeting_flag_filters?: FeatureFlagFilters + linked_insight_id?: number | null conditions: SurveyDisplayConditions | null appearance: SurveyAppearance | null questions: (BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion)[]