- {{ t('frontend-onboarding-setup-cli-outcomes-v2-v3') }}
+ {{ t('frontend-onboarding-setup-cli-outcomes-v2-v4') }}
{{ t('frontend-onboarding-setup-cli-outcomes-description') }}
@@ -604,15 +672,15 @@ displayStore.defaultBack = '/dashboard'
- {{ t('frontend-onboarding-daily-setup-cli-outcomes-v2-v3') }}
+ {{ t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4') }}
{{ t('frontend-onboarding-daily-setup-cli-outcomes-description') }}
diff --git a/src/pages/onboarding/app.vue b/src/pages/onboarding/app.vue
index ca4860bce6..720cab6ad3 100644
--- a/src/pages/onboarding/app.vue
+++ b/src/pages/onboarding/app.vue
@@ -49,8 +49,8 @@ onMounted(async () => {
-
-
meta:
+ layout: naked
middleware: auth
diff --git a/src/services/adminFrontendOnboarding.ts b/src/services/adminFrontendOnboarding.ts
index 256b04ca5d..1f6bf15cfb 100644
--- a/src/services/adminFrontendOnboarding.ts
+++ b/src/services/adminFrontendOnboarding.ts
@@ -52,20 +52,30 @@ export interface FrontendOnboardingDailyConversionPoint {
conversion_percent: number | null
}
+export interface FrontendOnboardingKpis {
+ attempts: number
+ completed: number
+ completion_rate: number
+ median_completion_ms: number | null
+ largest_dropoff: FrontendOnboardingLargestDropoff | null
+ comparison: FrontendOnboardingComparison
+}
+
+export interface FrontendOnboardingDailyConversions {
+ intent_to_details: FrontendOnboardingDailyConversionPoint[]
+ details_to_organization: FrontendOnboardingDailyConversionPoint[]
+ organization_to_setup: FrontendOnboardingDailyConversionPoint[]
+}
+
export interface FrontendOnboardingAnalytics {
- kpis: {
- attempts: number
- completed: number
- completion_rate: number
- median_completion_ms: number | null
- largest_dropoff: FrontendOnboardingLargestDropoff | null
- comparison: FrontendOnboardingComparison
- }
+ kpis: FrontendOnboardingKpis
+ v4_kpis?: FrontendOnboardingKpis
daily_attempts: Array<{
date: string
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts?: number
}>
deduplicated: {
daily_attempts: Array<{
@@ -73,20 +83,20 @@ export interface FrontendOnboardingAnalytics {
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts?: number
}>
funnels: {
v3: FrontendOnboardingFunnelStage[]
+ v4?: FrontendOnboardingFunnelStage[]
}
}
- daily_conversions: {
- intent_to_details: FrontendOnboardingDailyConversionPoint[]
- details_to_organization: FrontendOnboardingDailyConversionPoint[]
- organization_to_setup: FrontendOnboardingDailyConversionPoint[]
- }
+ daily_conversions: FrontendOnboardingDailyConversions
+ v4_daily_conversions?: FrontendOnboardingDailyConversions
funnels: {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
v3: FrontendOnboardingFunnelStage[]
+ v4?: FrontendOnboardingFunnelStage[]
}
v2_graph: {
nodes: Array<{
@@ -100,12 +110,24 @@ export interface FrontendOnboardingAnalytics {
count: number
}>
}
+ v4_graph?: {
+ nodes: Array<{
+ key: string
+ count: number
+ }>
+ }
v2_v3_setup_cli_outcomes: {
total_users: number
cli_only: number
cli_and_ai_instructions: number
no_cli: number
}
+ v2_v4_setup_cli_outcomes?: {
+ total_users: number
+ cli_only: number
+ cli_and_ai_instructions: number
+ no_cli: number
+ }
daily_setup_cli_outcomes: FrontendOnboardingDailySetupCliOutcomePoint[]
posthog_configured: boolean
posthog_connected: boolean
@@ -194,6 +216,7 @@ export function buildFrontendOnboardingDailySeries(
v1Label: string,
v2Label: string,
v3Label: string,
+ v4Label: string,
): FrontendOnboardingDailySeries[] {
return [
{
@@ -211,6 +234,11 @@ export function buildFrontendOnboardingDailySeries(
color: '#10b981',
data: dailyAttempts.map(({ date, v3_attempts }) => ({ date, value: v3_attempts })),
},
+ {
+ label: v4Label,
+ color: '#f59e0b',
+ data: dailyAttempts.map(({ date, v4_attempts = 0 }) => ({ date, value: v4_attempts })),
+ },
]
}
@@ -289,7 +317,7 @@ export function buildFrontendOnboardingFunnelSummaries(
export function buildFrontendOnboardingGraphMetrics(
definitions: readonly FrontendOnboardingGraphMetricDefinition[],
- nodes: readonly FrontendOnboardingAnalytics['v3_graph']['nodes'][number][],
+ nodes: readonly { key: string, count: number }[],
appDetailsCount: number | undefined,
): Record
{
const counts = new Map(nodes.map(node => [node.key, node.count]))
diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts
index 8ef59313f0..a1db8581d8 100644
--- a/src/utils/onboardingProgressAnalytics.ts
+++ b/src/utils/onboardingProgressAnalytics.ts
@@ -1,9 +1,9 @@
import { pushEvent } from '~/services/posthog'
-export const ONBOARDING_ANALYTICS_VERSION = 3
+export const ONBOARDING_ANALYTICS_VERSION = 4
export type OnboardingAnalyticsFlow = 'pre_org' | 'existing_org'
-export type OnboardingAnalyticsStep = 'intent' | 'details' | 'organization' | 'choice' | 'install' | 'setup'
+export type OnboardingAnalyticsStep = 'welcome' | 'intent' | 'details' | 'app_name' | 'app_id' | 'app_icon' | 'organization' | 'choice' | 'install' | 'setup'
export type OnboardingCopyEvent = 'onboarding_ai_instructions_copied' | 'onboarding_cli_command_copied'
export type OnboardingIntent = 'ota' | 'builder' | 'both' | 'exploring'
export type OnboardingInteractionEvent
@@ -18,8 +18,14 @@ export type OnboardingInteractionEvent
| 'onboarding_technical_invite_opened'
| 'onboarding_technical_invite_succeeded'
export type OnboardingDetailsEvent
- = | 'onboarding_app_id_entered'
+ = | 'onboarding_app_creation_failed'
+ | 'onboarding_app_creation_started'
+ | 'onboarding_app_creation_succeeded'
+ | 'onboarding_app_icon_import_selected'
+ | 'onboarding_app_icon_removed'
+ | 'onboarding_app_id_entered'
| 'onboarding_app_id_help_opened'
+ | 'onboarding_app_id_suggestion_selected'
| 'onboarding_app_icon_picked'
| 'onboarding_app_icon_picker_closed_without_selection'
| 'onboarding_app_icon_picker_open_failed'
@@ -32,6 +38,12 @@ export type OnboardingDetailsEvent
| 'onboarding_store_import_shown'
| 'onboarding_store_import_submitted'
| 'onboarding_store_import_succeeded'
+ | 'onboarding_store_icon_import_failed'
+ | 'onboarding_store_icon_import_hidden'
+ | 'onboarding_store_icon_import_shown'
+ | 'onboarding_store_icon_import_submitted'
+ | 'onboarding_store_icon_import_succeeded'
+ | 'onboarding_store_icon_url_entered'
| 'onboarding_store_url_entered'
type AnalyticsPrimitive = string | number | boolean
@@ -60,8 +72,24 @@ export interface OnboardingStepCompletionProperties {
}
export interface OnboardingDetailsEventProperties {
+ app_id_source?: 'generated' | 'manual' | 'store'
+ failure_reason?: 'all_conflicts' | 'request_error'
field_length?: number
- icon_source?: 'file' | 'store'
+ has_icon?: boolean
+ icon_source?: 'file' | 'none' | 'store'
+ used_fallback?: boolean
+}
+
+export function resolveOnboardingAppIconSource(options: {
+ canUseStoreImportPreview: boolean
+ hasSelectedIconFile: boolean
+ localIconPreview: string
+}): NonNullable {
+ if (options.hasSelectedIconFile || options.localIconPreview.startsWith('data:image/'))
+ return 'file'
+ if (options.canUseStoreImportPreview)
+ return 'store'
+ return 'none'
}
export interface OnboardingInteractionProperties {
@@ -76,16 +104,29 @@ export interface OnboardingCopyEventProperties {
setup_command: 'builder' | 'ota'
}
-export type OnboardingDetailsField = 'app_id' | 'app_name' | 'store_url'
+export type OnboardingDetailsField = 'app_id' | 'app_name' | 'icon_store_url' | 'store_url'
export function createOnboardingDetailsFieldDebouncer(
- emit: (name: OnboardingDetailsEvent, properties: OnboardingDetailsEventProperties) => void,
+ emit: (
+ name: OnboardingDetailsEvent,
+ step: OnboardingAnalyticsStep,
+ properties: OnboardingDetailsEventProperties,
+ ) => void,
delayMs = 1_000,
) {
const timers = new Map>()
- const pending = new Map()
-
- function schedule(name: OnboardingDetailsEvent, field: OnboardingDetailsField, value: string) {
+ const pending = new Map()
+
+ function schedule(
+ name: OnboardingDetailsEvent,
+ field: OnboardingDetailsField,
+ step: OnboardingAnalyticsStep,
+ value: string,
+ ) {
const activeTimer = timers.get(field)
if (activeTimer)
clearTimeout(activeTimer)
@@ -97,10 +138,10 @@ export function createOnboardingDetailsFieldDebouncer(
return
}
- const event = { name, properties: { field_length: normalizedValue.length } }
+ const event = { name, step, properties: { field_length: normalizedValue.length } }
pending.set(field, event)
timers.set(field, setTimeout(() => {
- emit(event.name, event.properties)
+ emit(event.name, event.step, event.properties)
timers.delete(field)
pending.delete(field)
}, delayMs))
@@ -110,7 +151,7 @@ export function createOnboardingDetailsFieldDebouncer(
for (const timer of timers.values())
clearTimeout(timer)
for (const event of pending.values())
- emit(event.name, event.properties)
+ emit(event.name, event.step, event.properties)
timers.clear()
pending.clear()
}
@@ -270,24 +311,32 @@ export function createOnboardingProgressTracker(options: CreateOnboardingProgres
safelyCapture('onboarding_step_completed', properties)
}
- function trackDetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) {
- const properties = sharedProperties('details')
+ function trackEvent(
+ name: OnboardingDetailsEvent | OnboardingInteractionEvent,
+ step: OnboardingAnalyticsStep,
+ details: OnboardingDetailsEventProperties | OnboardingInteractionProperties,
+ ) {
+ const properties = sharedProperties(step)
if (!properties)
return
safelyCapture(name, { ...properties, ...details })
}
+ function trackDetailsEvent(
+ name: OnboardingDetailsEvent,
+ step: OnboardingAnalyticsStep,
+ details: OnboardingDetailsEventProperties = {},
+ ) {
+ trackEvent(name, step, details)
+ }
+
function trackStepEvent(
name: OnboardingInteractionEvent,
step: OnboardingAnalyticsStep,
details: OnboardingInteractionProperties = {},
) {
- const properties = sharedProperties(step)
- if (!properties)
- return
-
- safelyCapture(name, { ...properties, ...details })
+ trackEvent(name, step, details)
}
function trackCopyEvent(name: OnboardingCopyEvent, details: OnboardingCopyEventProperties) {
diff --git a/src/utils/userOnboardingProgress.ts b/src/utils/userOnboardingProgress.ts
index e9a8764214..cd70111ffd 100644
--- a/src/utils/userOnboardingProgress.ts
+++ b/src/utils/userOnboardingProgress.ts
@@ -1,6 +1,5 @@
import type {
OnboardingAnalyticsFlow,
- OnboardingAnalyticsStep,
OnboardingIntent,
} from '~/utils/onboardingProgressAnalytics'
@@ -8,14 +7,18 @@ export const USER_ONBOARDING_STATUSES = ['in_progress', 'completed', 'abandoned'
export const USER_ONBOARDING_STEPS = ['intent', 'details', 'organization', 'choice', 'install', 'setup'] as const
export const USER_ONBOARDING_FLOWS = ['pre_org', 'existing_org'] as const
export const USER_ONBOARDING_INTENTS = ['ota', 'builder', 'both', 'exploring'] as const
+export const USER_ONBOARDING_DETAILS_STEPS = ['name', 'app_id', 'icon'] as const
export type UserOnboardingStatus = typeof USER_ONBOARDING_STATUSES[number]
+export type UserOnboardingStep = typeof USER_ONBOARDING_STEPS[number]
+export type UserOnboardingDetailsStep = typeof USER_ONBOARDING_DETAILS_STEPS[number]
export interface UserOnboardingProgress {
status: UserOnboardingStatus
- step: OnboardingAnalyticsStep
+ step: UserOnboardingStep
flow: OnboardingAnalyticsFlow
intent?: OnboardingIntent
+ details_step?: UserOnboardingDetailsStep
app_name?: string
app_id?: string
existing_app?: boolean | null
@@ -32,9 +35,10 @@ export interface UserOnboardingProgress {
export interface UserOnboardingProgressInput {
status: UserOnboardingStatus
- step: OnboardingAnalyticsStep
+ step: UserOnboardingStep
flow: OnboardingAnalyticsFlow
intent?: OnboardingIntent | null
+ detailsStep?: UserOnboardingDetailsStep
appName?: string
appId?: string
existingApp?: boolean | null
@@ -118,6 +122,9 @@ function applyOptionalUserOnboardingFields(
if (isOneOf(raw.intent, USER_ONBOARDING_INTENTS))
progress.intent = raw.intent
+ if (isOneOf(raw.details_step, USER_ONBOARDING_DETAILS_STEPS))
+ progress.details_step = raw.details_step
+
const appName = optionalTrimmedString(raw.app_name)
if (appName)
progress.app_name = appName
@@ -199,6 +206,9 @@ export function buildUserOnboardingProgress(input: UserOnboardingProgressInput):
if (input.intent)
progress.intent = input.intent
+ if (input.detailsStep)
+ progress.details_step = input.detailsStep
+
const appName = optionalTrimmedString(input.appName)
if (appName)
progress.app_name = appName
@@ -241,9 +251,9 @@ export function buildUserOnboardingProgress(input: UserOnboardingProgressInput):
}
export function clampResumableOnboardingStep(
- step: OnboardingAnalyticsStep,
+ step: UserOnboardingStep,
flow: OnboardingAnalyticsFlow,
-): OnboardingAnalyticsStep {
+): UserOnboardingStep {
if (flow === 'pre_org' && (step === 'choice' || step === 'install' || step === 'setup'))
return 'organization'
return step
@@ -262,6 +272,7 @@ export function shouldPromptOnboardingResume(
return Boolean(
progress.intent
+ || (progress.details_step !== undefined && progress.details_step !== 'name')
|| progress.app_name
|| progress.existing_app === true
|| progress.existing_app === false
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts
index 7358108dbe..ac4564e53a 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts
@@ -21,8 +21,14 @@ export const FRONTEND_ONBOARDING_ATTEMPT_LIMIT = 50_000
export const FRONTEND_ONBOARDING_MAX_RANGE_MS = 365 * 24 * 60 * 60 * 1000
const ONBOARDING_INTERACTION_EVENTS = [
+ 'onboarding_app_creation_failed',
+ 'onboarding_app_creation_started',
+ 'onboarding_app_creation_succeeded',
+ 'onboarding_app_icon_import_selected',
+ 'onboarding_app_icon_removed',
'onboarding_app_id_entered',
'onboarding_app_id_help_opened',
+ 'onboarding_app_id_suggestion_selected',
'onboarding_app_icon_picked',
'onboarding_app_icon_picker_closed_without_selection',
'onboarding_app_icon_picker_open_failed',
@@ -35,6 +41,12 @@ const ONBOARDING_INTERACTION_EVENTS = [
'onboarding_store_import_shown',
'onboarding_store_import_submitted',
'onboarding_store_import_succeeded',
+ 'onboarding_store_icon_import_failed',
+ 'onboarding_store_icon_import_hidden',
+ 'onboarding_store_icon_import_shown',
+ 'onboarding_store_icon_import_submitted',
+ 'onboarding_store_icon_import_succeeded',
+ 'onboarding_store_icon_url_entered',
'onboarding_store_url_entered',
'onboarding_organization_import_opened',
'onboarding_organization_import_submitted',
@@ -194,7 +206,7 @@ export function buildFrontendOnboardingHogql(startDate: string, cohortEndDate: s
attempt_id,
toString(argMin(person_id, timestamp)) AS person_id,
toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'intent')) AS intent_ms,
- toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'details')) AS details_ms,
+ toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step IN ('details', 'app_name'))) AS details_ms,
toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'organization')) AS organization_ms,
toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step = 'setup')) AS setup_ms,
groupUniqArrayIf(tuple(event, toUnixTimestamp64Milli(timestamp)), event IN (${interactionEventAllowlist})) AS interaction_events,
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
index 8a66ba38b7..21605f40ae 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics_model.ts
@@ -1,4 +1,4 @@
-export const FRONTEND_ONBOARDING_VERSIONS = [1, 2, 3] as const
+export const FRONTEND_ONBOARDING_VERSIONS = [1, 2, 3, 4] as const
export type FrontendOnboardingVersion = typeof FRONTEND_ONBOARDING_VERSIONS[number]
export const FRONTEND_ONBOARDING_FOLLOWUP_MS = 24 * 60 * 60 * 1000
export const FRONTEND_ONBOARDING_PRODUCTION_HOST = ['console', 'capgo', 'app'].join('.')
@@ -57,6 +57,7 @@ export interface FrontendOnboardingDailyAttempt {
v1_attempts: number
v2_attempts: number
v3_attempts: number
+ v4_attempts: number
}
export interface FrontendOnboardingDailyConversion {
@@ -75,11 +76,13 @@ export interface FrontendOnboardingSetupCliOutcomes {
export interface FrontendOnboardingAnalytics {
kpis: FrontendOnboardingPeriodKpis & { comparison: FrontendOnboardingComparison }
+ v4_kpis: FrontendOnboardingPeriodKpis & { comparison: FrontendOnboardingComparison }
daily_attempts: FrontendOnboardingDailyAttempt[]
deduplicated: {
daily_attempts: FrontendOnboardingDailyAttempt[]
funnels: {
v3: FrontendOnboardingFunnelStage[]
+ v4: FrontendOnboardingFunnelStage[]
}
}
daily_conversions: {
@@ -87,10 +90,16 @@ export interface FrontendOnboardingAnalytics {
details_to_organization: FrontendOnboardingDailyConversion[]
organization_to_setup: FrontendOnboardingDailyConversion[]
}
+ v4_daily_conversions: {
+ intent_to_details: FrontendOnboardingDailyConversion[]
+ details_to_organization: FrontendOnboardingDailyConversion[]
+ organization_to_setup: FrontendOnboardingDailyConversion[]
+ }
funnels: {
v1: FrontendOnboardingFunnelStage[]
v2: FrontendOnboardingFunnelStage[]
v3: FrontendOnboardingFunnelStage[]
+ v4: FrontendOnboardingFunnelStage[]
}
v2_graph: {
nodes: Array<{ key: string, count: number }>
@@ -98,7 +107,11 @@ export interface FrontendOnboardingAnalytics {
v3_graph: {
nodes: Array<{ key: string, count: number }>
}
+ v4_graph: {
+ nodes: Array<{ key: string, count: number }>
+ }
v2_v3_setup_cli_outcomes: FrontendOnboardingSetupCliOutcomes
+ v2_v4_setup_cli_outcomes: FrontendOnboardingSetupCliOutcomes
}
const DAY_MS = 24 * 60 * 60 * 1000
@@ -248,7 +261,7 @@ function eachUtcDate(startMs: number, endMs: number): string[] {
}
function buildDailyAttempts(attempts: FrontendOnboardingAttempt[], startMs: number, endMs: number): FrontendOnboardingDailyAttempt[] {
- const emptyCounts = () => ({ v1_attempts: 0, v2_attempts: 0, v3_attempts: 0 })
+ const emptyCounts = () => ({ v1_attempts: 0, v2_attempts: 0, v3_attempts: 0, v4_attempts: 0 })
const attemptsByDate = new Map>()
for (const attempt of attempts) {
const date = utcDate(attempt.intentMs)
@@ -321,7 +334,7 @@ function buildInteractionGraph(attempts: FrontendOnboardingAttempt[]): Array<{ k
.map(([key, count]) => ({ key, count }))
}
-function buildV2V3SetupCliOutcomes(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingSetupCliOutcomes {
+function buildSetupCliOutcomes(attempts: FrontendOnboardingAttempt[]): FrontendOnboardingSetupCliOutcomes {
const outcomesByPerson = new Map()
for (const attempt of attempts) {
@@ -384,38 +397,62 @@ export function buildFrontendOnboardingAnalytics(
const currentV1Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 1)
const currentV2Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2)
const currentV3Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 3)
- const currentV2AndV3Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2 || attempt.onboardingVersion === 3)
+ const currentV4Attempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 4)
+ const currentV2V3SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion === 2 || attempt.onboardingVersion === 3)
+ const currentV2V4SetupCliOutcomeAttempts = currentAttempts.filter(attempt => attempt.onboardingVersion >= 2)
const currentV3ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 3
&& attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
&& attempt.intentMs < currentEndMs)
+ const currentV4ConversionAttempts = attempts.filter(attempt => attempt.onboardingVersion === 4
+ && attempt.intentMs >= currentStartMs - FRONTEND_ONBOARDING_FOLLOWUP_MS
+ && attempt.intentMs < currentEndMs)
const previousV3Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 3)
+ const previousV4Attempts = previousAttempts.filter(attempt => attempt.onboardingVersion === 4)
const currentV3 = summarizePeriod(currentV3Attempts)
const previousV3 = summarizePeriod(previousV3Attempts)
+ const currentV4 = summarizePeriod(currentV4Attempts)
+ const previousV4 = summarizePeriod(previousV4Attempts)
const deduplicatedCurrentAttempts = selectDeduplicatedAttempts(currentAttempts)
const deduplicatedCurrentV3Attempts = selectDeduplicatedAttempts(currentV3Attempts)
+ const deduplicatedCurrentV4Attempts = selectDeduplicatedAttempts(currentV4Attempts)
return {
kpis: {
...currentV3.kpis,
comparison: comparePeriods(currentV3.kpis, previousV3.kpis),
},
+ v4_kpis: {
+ ...currentV4.kpis,
+ comparison: comparePeriods(currentV4.kpis, previousV4.kpis),
+ },
daily_attempts: buildDailyAttempts(currentAttempts, currentStartMs, currentEndMs),
deduplicated: {
daily_attempts: buildDailyAttempts(deduplicatedCurrentAttempts, currentStartMs, currentEndMs),
- funnels: { v3: buildFunnel(deduplicatedCurrentV3Attempts) },
+ funnels: {
+ v3: buildFunnel(deduplicatedCurrentV3Attempts),
+ v4: buildFunnel(deduplicatedCurrentV4Attempts),
+ },
},
daily_conversions: {
intent_to_details: buildDailyConversion(attempts, currentStartMs, currentEndMs, 'intent', 'details'),
details_to_organization: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
organization_to_setup: buildDailyConversion(currentV3ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
},
+ v4_daily_conversions: {
+ intent_to_details: buildDailyConversion(attempts, currentStartMs, currentEndMs, 'intent', 'details'),
+ details_to_organization: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'details', 'organization'),
+ organization_to_setup: buildDailyConversion(currentV4ConversionAttempts, currentStartMs, currentEndMs, 'organization', 'setup'),
+ },
funnels: {
v1: buildFunnel(currentV1Attempts),
v2: buildFunnel(currentV2Attempts),
- v3: currentV3.funnel,
+ v3: buildFunnel(currentV3Attempts),
+ v4: currentV4.funnel,
},
v2_graph: { nodes: buildInteractionGraph(currentV2Attempts) },
v3_graph: { nodes: buildInteractionGraph(currentV3Attempts) },
- v2_v3_setup_cli_outcomes: buildV2V3SetupCliOutcomes(currentV2AndV3Attempts),
+ v4_graph: { nodes: buildInteractionGraph(currentV4Attempts) },
+ v2_v3_setup_cli_outcomes: buildSetupCliOutcomes(currentV2V3SetupCliOutcomeAttempts),
+ v2_v4_setup_cli_outcomes: buildSetupCliOutcomes(currentV2V4SetupCliOutcomeAttempts),
}
}
diff --git a/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts b/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
index ee5ab26941..2b865d53a4 100644
--- a/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
+++ b/supabase/functions/_backend/utils/frontend_onboarding_daily_setup_cli_outcomes.ts
@@ -3,7 +3,7 @@ import type {
FrontendOnboardingDailySetupCliEvent,
FrontendOnboardingDailySetupCliEventKind,
} from './frontend_onboarding_daily_setup_cli_outcomes_model.ts'
-import { FRONTEND_ONBOARDING_PRODUCTION_HOST } from './frontend_onboarding_analytics_model.ts'
+import { FRONTEND_ONBOARDING_PRODUCTION_HOST, FRONTEND_ONBOARDING_VERSIONS } from './frontend_onboarding_analytics_model.ts'
import { cloudlogErr } from './logging.ts'
import { queryPosthogHogql } from './posthog_read.ts'
@@ -70,6 +70,8 @@ export function buildFrontendOnboardingDailySetupCliHogql(
endDate: string,
followupEndDate: string,
): string {
+ const setupVersionAllowlist = FRONTEND_ONBOARDING_VERSIONS.filter(version => version >= 2).join(', ')
+
return `
WITH setup_people AS (
SELECT DISTINCT
@@ -78,7 +80,7 @@ export function buildFrontendOnboardingDailySetupCliHogql(
WHERE event = 'onboarding_step_viewed'
AND JSONExtractString(toString(properties), 'flow') = 'pre_org'
AND JSONExtractString(toString(properties), '$host') = ${sqlStr(FRONTEND_ONBOARDING_PRODUCTION_HOST)}
- AND toIntOrZero(toString(properties.onboarding_version)) IN (2, 3)
+ AND toIntOrZero(toString(properties.onboarding_version)) IN (${setupVersionAllowlist})
AND JSONExtractString(toString(properties), 'step') = 'setup'
AND timestamp >= parseDateTimeBestEffort(${sqlStr(startDate)})
AND timestamp < parseDateTimeBestEffort(${sqlStr(endDate)})
@@ -105,7 +107,7 @@ export function buildFrontendOnboardingDailySetupCliHogql(
selected_events.event IN ('onboarding_step_viewed', 'onboarding_cli_command_copied', 'onboarding_ai_instructions_copied')
AND JSONExtractString(toString(selected_events.properties), 'flow') = 'pre_org'
AND JSONExtractString(toString(selected_events.properties), '$host') = ${sqlStr(FRONTEND_ONBOARDING_PRODUCTION_HOST)}
- AND toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (2, 3)
+ AND toIntOrZero(toString(selected_events.properties.onboarding_version)) IN (${setupVersionAllowlist})
AND JSONExtractString(toString(selected_events.properties), 'step') = 'setup'
)
)
diff --git a/supabase/migrations/20260818152131_user_onboarding_details_step_comment.sql b/supabase/migrations/20260818152131_user_onboarding_details_step_comment.sql
new file mode 100644
index 0000000000..0304d65e9b
--- /dev/null
+++ b/supabase/migrations/20260818152131_user_onboarding_details_step_comment.sql
@@ -0,0 +1,6 @@
+COMMENT ON COLUMN public.users.onboarding IS
+'Persisted create-app onboarding wizard progress for resume and admin '
+'drop-off. Keys: status, step, flow, intent, details_step, app_name, app_id, '
+'existing_app, existing_app_setup, store_url, imported_store_app_id, '
+'org_name, estimated_users_index, onboarding_attempt_id, last_run_id, '
+'updated_at, completed_at.';
diff --git a/tests/admin-chart-card-collapse.unit.test.ts b/tests/admin-chart-card-collapse.unit.test.ts
index fbc13c82f0..c9c305a4bb 100644
--- a/tests/admin-chart-card-collapse.unit.test.ts
+++ b/tests/admin-chart-card-collapse.unit.test.ts
@@ -388,8 +388,8 @@ describe('admin chart card minimize control', () => {
it('uses the collapsible card for every custom frontend onboarding graph', () => {
const source = readFileSync(resolve(process.cwd(), 'src/pages/admin/dashboard/frontend-onboarding.vue'), 'utf8')
- expect(source).toContain(`:title="t('frontend-onboarding-funnel-v3')"`)
- expect(source).toContain(`:title="t('frontend-onboarding-graph-v3')"`)
+ expect(source).toContain(':title="displayedFunnelTitle"')
+ expect(source).toContain(':title="graphTitle"')
expect(source).toContain(`:title="t('frontend-onboarding-funnel-v1-legacy')"`)
expect(source.match(/ {
},
},
daily_attempts: [
- { date: '2026-08-10', v1_attempts: 6, v2_attempts: 3, v3_attempts: 2 },
- { date: '2026-08-09', v1_attempts: 4, v2_attempts: 2, v3_attempts: 1 },
+ { date: '2026-08-10', v1_attempts: 6, v2_attempts: 3, v3_attempts: 2, v4_attempts: 4 },
+ { date: '2026-08-09', v1_attempts: 4, v2_attempts: 2, v3_attempts: 1, v4_attempts: 2 },
],
daily_conversions: {
intent_to_details: [
@@ -95,15 +95,24 @@ describe('admin frontend onboarding dashboard', () => {
{ key: 'organization', label: 'Organization', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 },
{ key: 'setup', label: 'Setup reached', reached: 1, of_start_percent: 25, dropoff_percent: 50 },
],
+ v4: [
+ { key: 'intent', label: 'Intent', reached: 4, of_start_percent: 100, dropoff_percent: 0 },
+ { key: 'details', label: 'App details', reached: 3, of_start_percent: 75, dropoff_percent: 25 },
+ { key: 'organization', label: 'Organization', reached: 2, of_start_percent: 50, dropoff_percent: 1 / 3 * 100 },
+ { key: 'setup', label: 'Setup reached', reached: 1, of_start_percent: 25, dropoff_percent: 50 },
+ ],
},
deduplicated: {
daily_attempts: [
- { date: '2026-08-10', v1_attempts: 5, v2_attempts: 2, v3_attempts: 1 },
+ { date: '2026-08-10', v1_attempts: 5, v2_attempts: 2, v3_attempts: 1, v4_attempts: 3 },
],
funnels: {
v3: [
{ key: 'intent', label: 'Intent', reached: 3, of_start_percent: 100, dropoff_percent: 0 },
],
+ v4: [
+ { key: 'intent', label: 'Intent', reached: 3, of_start_percent: 100, dropoff_percent: 0 },
+ ],
},
},
v2_graph: {
@@ -120,6 +129,13 @@ describe('admin frontend onboarding dashboard', () => {
{ key: 'import_clicked', count: 1 },
],
},
+ v4_graph: {
+ nodes: [
+ { key: 'details', count: 4 },
+ { key: 'store_opened', count: 3 },
+ { key: 'import_clicked', count: 2 },
+ ],
+ },
v2_v3_setup_cli_outcomes: {
total_users: 2,
cli_only: 1,
@@ -162,7 +178,7 @@ describe('admin frontend onboarding dashboard', () => {
}
it.concurrent('adapts split daily attempts into ordered version chart series', () => {
- expect(buildFrontendOnboardingDailySeries(analytics.daily_attempts, 'V1', 'V2', 'V3')).toEqual([
+ expect(buildFrontendOnboardingDailySeries(analytics.daily_attempts, 'V1', 'V2', 'V3', 'V4')).toEqual([
{
label: 'V1',
color: '#a78bfa',
@@ -187,11 +203,25 @@ describe('admin frontend onboarding dashboard', () => {
{ date: '2026-08-09', value: 1 },
],
},
+ {
+ label: 'V4',
+ color: '#f59e0b',
+ data: [
+ { date: '2026-08-10', value: 4 },
+ { date: '2026-08-09', value: 2 },
+ ],
+ },
])
- expect(buildFrontendOnboardingDailySeries([], 'V1', 'V2', 'V3')).toEqual([
+ expect(buildFrontendOnboardingDailySeries([], 'V1', 'V2', 'V3', 'V4')).toEqual([
{ label: 'V1', color: '#a78bfa', data: [] },
{ label: 'V2', color: '#06b6d4', data: [] },
{ label: 'V3', color: '#10b981', data: [] },
+ { label: 'V4', color: '#f59e0b', data: [] },
+ ])
+ expect(buildFrontendOnboardingDailySeries([
+ { date: '2026-08-08', v1_attempts: 1, v2_attempts: 2, v3_attempts: 3 },
+ ], 'V1', 'V2', 'V3', 'V4')[3]?.data).toEqual([
+ { date: '2026-08-08', value: 0 },
])
})
@@ -592,9 +622,11 @@ describe('admin frontend onboarding dashboard', () => {
expect(onLoadingCallback).toContain('if (!value)')
expect(onLoadingCallback).toContain('isLoading.value = false')
expect(source).toContain('const visibleAnalytics = computed(() => isLoadingStats.value ? null : analytics.value)')
+ expect(source).toContain("rawLatestFunnel.value.version === 'v4'\n ? visibleAnalytics.value?.v4_kpis ?? visibleAnalytics.value?.kpis")
+ expect(source).toContain("rawLatestFunnel.value.version === 'v4'\n ? visibleAnalytics.value?.v4_daily_conversions ?? visibleAnalytics.value?.daily_conversions")
})
- it.concurrent('uses the existing admin dashboard components for v2 and the legacy funnel', async () => {
+ it.concurrent('uses the existing admin dashboard components for v4 and the legacy funnel', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const template = source.slice(source.indexOf(''))
@@ -607,14 +639,15 @@ describe('admin frontend onboarding dashboard', () => {
expect(source.match(/)/g)).toHaveLength(3)
expect(source.match(/)/g)).toHaveLength(2)
expect(source.match(/)/g)).toHaveLength(1)
- expect(source).toContain('')
- expect(source).toContain('')
+ expect(source).toContain('')
+ expect(source).toContain('')
expect(source).toContain('')
- expect(source).not.toContain('')
+ expect(source).not.toContain('')
expect(source).not.toContain('')
expect(source).toContain('orientation="vertical"')
expect(source).toContain(':values="setupCliOutcomeValues"')
- expect(source).toContain('visibleAnalytics.value?.v2_v3_setup_cli_outcomes')
+ expect(source).toContain('visibleAnalytics.value?.v2_v4_setup_cli_outcomes')
+ expect(source).toContain('?? visibleAnalytics.value?.v2_v3_setup_cli_outcomes')
expect(source).toContain('buildFrontendOnboardingDailySetupCliSeries')
expect(source).toContain('visibleAnalytics.value?.daily_setup_cli_outcomes')
expect(source).toContain(':series="dailySetupCliSeries"')
@@ -622,26 +655,28 @@ describe('admin frontend onboarding dashboard', () => {
expect(source).not.toContain('v-if="isLoadingStats" class="grid min-h-72 place-items-center"')
expect(source).toContain(`t('frontend-onboarding-version-2')`)
expect(source).toContain(`t('frontend-onboarding-version-3')`)
- expect(source).toContain(`t('frontend-onboarding-funnel-v3')`)
+ expect(source).toContain(`t('frontend-onboarding-version-4')`)
+ expect(source).toContain(`'frontend-onboarding-funnel-v4'`)
expect(source).toContain(`t('frontend-onboarding-funnel-v1-legacy')`)
expect(source).not.toContain(`t('frontend-onboarding-demo-data')`)
expect(source).toContain('buildFrontendOnboardingGraphMetrics')
- expect(source).toContain('visibleAnalytics.value?.v3_graph.nodes')
- expect(source).not.toContain('onboardingGraphV3Demo')
+ expect(source).toContain('visibleAnalytics.value?.v4_graph?.nodes')
+ expect(source).toContain('visibleAnalytics.value?.v3_graph?.nodes')
+ expect(source).not.toContain('onboardingGraphV4Demo')
expect(source).toContain('buildFrontendOnboardingFunnelSummaries')
expect(template).toContain('summary.conversion_percent')
expect(template).not.toContain('of_start_percent')
- const v3FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v3')`)
- const intentDetailsChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
- const detailsOrganizationChartIndex = source.indexOf(`t('frontend-onboarding-daily-details-to-organization')`)
- const organizationSetupChartIndex = source.indexOf(`t('frontend-onboarding-daily-organization-to-setup')`)
- const graphIndex = source.indexOf(`t('frontend-onboarding-graph-v3')`)
- const cliOutcomeIndex = source.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v3')`)
- const dailyCliOutcomeIndex = source.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v3')`)
- const legacyIndex = source.indexOf(`t('frontend-onboarding-funnel-v1-legacy')`)
- expect(v3FunnelIndex).toBeLessThan(graphIndex)
- expect(v3FunnelIndex).toBeLessThan(intentDetailsChartIndex)
+ const v4FunnelIndex = template.indexOf('chart-id="funnel-v4"')
+ const intentDetailsChartIndex = template.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
+ const detailsOrganizationChartIndex = template.indexOf(`t('frontend-onboarding-daily-details-to-organization')`)
+ const organizationSetupChartIndex = template.indexOf(`t('frontend-onboarding-daily-organization-to-setup')`)
+ const graphIndex = template.indexOf('chart-id="journey-graph-v4"')
+ const cliOutcomeIndex = template.indexOf(`t('frontend-onboarding-setup-cli-outcomes-v2-v4')`)
+ const dailyCliOutcomeIndex = template.indexOf(`t('frontend-onboarding-daily-setup-cli-outcomes-v2-v4')`)
+ const legacyIndex = template.indexOf(`t('frontend-onboarding-funnel-v1-legacy')`)
+ expect(v4FunnelIndex).toBeLessThan(graphIndex)
+ expect(v4FunnelIndex).toBeLessThan(intentDetailsChartIndex)
expect(intentDetailsChartIndex).toBeLessThan(detailsOrganizationChartIndex)
expect(detailsOrganizationChartIndex).toBeLessThan(organizationSetupChartIndex)
expect(organizationSetupChartIndex).toBeLessThan(graphIndex)
@@ -649,7 +684,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(cliOutcomeIndex).toBeLessThan(dailyCliOutcomeIndex)
expect(dailyCliOutcomeIndex).toBeLessThan(legacyIndex)
- const dailyCliOutcomeSection = source.slice(dailyCliOutcomeIndex, legacyIndex)
+ const dailyCliOutcomeSection = template.slice(dailyCliOutcomeIndex, legacyIndex)
expect(dailyCliOutcomeSection).toContain(':has-data="hasDailySetupCliOutcomeData"')
expect(dailyCliOutcomeSection).toContain(`t('frontend-onboarding-daily-setup-cli-outcomes-description')`)
expect(dailyCliOutcomeSection).toContain(' {
expect(source).toContain(`fromPoint: { x: 2130, y: 90 }, toPoint: { x: 2130, y: 870 }, style: 'dotted', arrow: false`)
expect(source).toContain(`fromPoint: { x: 2130, y: 540 }, to: 'organization', style: 'primary'`)
for (const eventKey of [
+ 'onboarding_app_creation_started',
+ 'onboarding_app_creation_succeeded',
+ 'onboarding_app_creation_failed',
'onboarding_organization_import_opened',
'onboarding_organization_import_submitted',
'onboarding_organization_import_succeeded',
@@ -679,6 +717,9 @@ describe('admin frontend onboarding dashboard', () => {
}
expect(source).toContain(`from: 'organization', to: 'organization_import_opened', style: 'branch'`)
expect(source).toContain(`from: 'organization', to: 'organization_invite_viewed', style: 'branch'`)
+ expect(source).toContain(`from: 'organization', to: 'app_creation_started', style: 'branch'`)
+ expect(source).toContain(`from: 'app_creation_started', to: 'app_creation_succeeded', style: 'branch'`)
+ expect(source).toContain(`from: 'app_creation_started', to: 'app_creation_failed', style: 'branch'`)
expect(source).toContain(`from: 'setup', to: 'technical_invite_opened', style: 'branch'`)
const statsCard = await readFile(new URL('../src/components/admin/AdminStatsCard.vue', import.meta.url), 'utf8')
@@ -687,30 +728,32 @@ describe('admin frontend onboarding dashboard', () => {
expect(funnelChart).toContain('class="d-loading d-loading-spinner d-loading-lg text-primary"')
})
- it.concurrent('shows independent local de-duplicate controls on the expanded daily attempts and v3 funnel cards', async () => {
+ it.concurrent('shows independent local de-duplicate controls on the expanded daily attempts and v4 funnel cards', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const control = await readFile(new URL('../src/components/admin/AdminChartDeduplicateControl.vue', import.meta.url), 'utf8')
const dailyAttemptsIndex = source.indexOf(`t('frontend-onboarding-daily-attempts')`)
- const v3FunnelIndex = source.indexOf(`t('frontend-onboarding-funnel-v3')`)
+ const v4FunnelIndex = source.indexOf('chart-id="funnel-v4"')
const nextChartIndex = source.indexOf(`t('frontend-onboarding-daily-intent-to-details')`)
expect(source).toContain('const deduplicateDailyAttempts = ref(false)')
- expect(source).toContain('const deduplicateV3Funnel = ref(false)')
+ expect(source).toContain('const deduplicateV4Funnel = ref(false)')
expect(source).toContain('const displayedDailyAttempts = computed')
expect(source).toContain('deduplicateDailyAttempts.value')
expect(source).toContain('visibleAnalytics.value?.deduplicated.daily_attempts')
- expect(source).toContain('const displayedV3Funnel = computed')
- expect(source).toContain('deduplicateV3Funnel.value')
- expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels.v3')
+ expect(source).toContain('const displayedV4Funnel = computed')
+ expect(source).toContain('deduplicateV4Funnel.value')
+ expect(source).toContain('visibleAnalytics.value?.deduplicated.funnels')
+ expect(source).toContain('stages: v4 ?? funnels?.v3 ?? []')
expect(source).toContain('buildFrontendOnboardingDailySeries(\n displayedDailyAttempts.value')
- expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV3Funnel.value)')
- expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV3Funnel.value)')
+ expect(source).toContain('buildFrontendOnboardingFunnelStages(displayedV4Funnel.value)')
+ expect(source).toContain('buildFrontendOnboardingFunnelSummaries(displayedV4Funnel.value)')
expect(source.match(/deduplicateDailyAttempts/g)).toHaveLength(3)
- expect(source.match(/deduplicateV3Funnel/g)).toHaveLength(3)
- expect(source.slice(dailyAttemptsIndex, v3FunnelIndex)).toContain(`:chart-label="t('frontend-onboarding-daily-attempts')"`)
- expect(source.slice(v3FunnelIndex, nextChartIndex)).toContain(`:chart-label="t('frontend-onboarding-funnel-v3')"`)
+ expect(source.match(/deduplicateV4Funnel/g)).toHaveLength(3)
+ expect(source.slice(dailyAttemptsIndex, v4FunnelIndex)).toContain(`:chart-label="t('frontend-onboarding-daily-attempts')"`)
+ expect(source.slice(v4FunnelIndex, nextChartIndex)).toContain(':chart-label="displayedFunnelTitle"')
expect(source).toContain('const result = await adminStore.fetchStats(\'frontend_onboarding_analytics\')')
expect(source).toContain('!Array.isArray(result.deduplicated?.daily_attempts)')
+ expect(source).toContain('!Array.isArray(result.deduplicated?.funnels?.v4)')
expect(source).toContain('!Array.isArray(result.deduplicated?.funnels?.v3)')
expect(source).toContain('throw new Error(\'Frontend onboarding analytics response is missing deduplicated chart data\')')
expect(control).toContain('data-test="deduplicate-by-user"')
@@ -721,6 +764,27 @@ describe('admin frontend onboarding dashboard', () => {
expect(control).toContain(`t('frontend-onboarding-deduplicate-by-user')`)
})
+ it.concurrent('labels v3 fallback data and resolves drop-off labels from the same funnel source', async () => {
+ const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
+ const template = source.slice(source.indexOf(''))
+
+ expect(source).toContain('const rawLatestFunnel = computed')
+ expect(source).toContain("version: v4 === undefined ? 'v3' : 'v4'")
+ expect(source).toContain('const displayedLatestFunnel = computed')
+ expect(source).toContain('const displayedFunnelTitle = computed')
+ expect(source).toContain(`t(displayedLatestFunnel.value.version === 'v4'`)
+ expect(source).toContain('const onboardingGraphSource = computed')
+ expect(source).toContain('const graphTitle = computed')
+ expect(source).toContain(`t(onboardingGraphSource.value.version === 'v4'`)
+ expect(source).toContain('const stages = rawLatestFunnel.value.stages')
+ expect(source).not.toContain('const stages = visibleAnalytics.value?.funnels.v4 ?? []')
+ expect(template).toContain(':title="displayedFunnelTitle"')
+ expect(template).toContain('{{ displayedFunnelTitle }}')
+ expect(template).toContain(':chart-label="displayedFunnelTitle"')
+ expect(template).toContain(':title="graphTitle"')
+ expect(template).toContain('{{ graphTitle }}')
+ })
+
it.concurrent('shows request failures instead of zero analytics', async () => {
const source = await readFile(new URL('../src/pages/admin/dashboard/frontend-onboarding.vue', import.meta.url), 'utf8')
const template = source.slice(source.indexOf(''))
@@ -765,6 +829,7 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-version-1']).toBe('Onboarding v1')
expect(messages['frontend-onboarding-version-2']).toBe('Onboarding v2')
expect(messages['frontend-onboarding-version-3']).toBe('Onboarding v3')
+ expect(messages['frontend-onboarding-version-4']).toBe('Onboarding v4')
expect(messages['frontend-onboarding-attempts']).toBe('Onboarding attempts')
expect(messages['frontend-onboarding-attempts-subtitle']).toBe('Unique onboarding attempts')
expect(messages['frontend-onboarding-completed']).toBe('Onboarding completed')
@@ -775,21 +840,26 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-daily-attempts']).toBe('Daily onboarding attempts')
expect(messages['frontend-onboarding-deduplicate-by-user']).toBe('De-duplicate by user')
expect(messages['frontend-onboarding-deduplicate-by-user-chart']).toBe('De-duplicate by user: {chart}')
- expect(messages['frontend-onboarding-daily-intent-to-details']).toBe('Daily Intent → App details conversion (v1, v2, and v3)')
- expect(messages['frontend-onboarding-daily-details-to-organization']).toBe('Daily App details → Organization conversion (v3)')
- expect(messages['frontend-onboarding-daily-organization-to-setup']).toBe('Daily Organization → Setup reached conversion (v3)')
+ expect(messages['frontend-onboarding-daily-intent-to-details']).toBe('Daily Intent → App details conversion (v1–v4)')
+ expect(messages['frontend-onboarding-daily-details-to-organization']).toBe('Daily App details → Organization conversion (v4)')
+ expect(messages['frontend-onboarding-daily-organization-to-setup']).toBe('Daily Organization → Setup reached conversion (v4)')
expect(messages['frontend-onboarding-daily-conversion']).toBe('Conversion')
expect(messages['frontend-onboarding-daily-conversion-attempts']).toBe('attempts')
expect(messages['frontend-onboarding-funnel-v2']).toBe('Onboarding funnel (v2)')
+ expect(messages['frontend-onboarding-funnel-v4']).toBe('Frontend onboarding funnel (v4)')
expect(messages['frontend-onboarding-funnel-v1-legacy']).toBe('Onboarding funnel (v1, legacy)')
expect(messages['frontend-onboarding-graph-v2']).toBe('Onboarding graph (v2)')
+ expect(messages['frontend-onboarding-graph-v4']).toBe('Frontend onboarding graph (v4)')
+ expect(messages['frontend-onboarding-graph-v4-description']).toBe('Explore behavior inside each onboarding stage')
expect(messages['frontend-onboarding-setup-cli-outcomes-v2-v3']).toBe('Setup → CLI outcomes (v2 and v3)')
+ expect(messages['frontend-onboarding-setup-cli-outcomes-v2-v4']).toBe('Setup → CLI outcomes (v2–v4)')
expect(messages['frontend-onboarding-setup-cli-outcomes-description']).toBe('Unique people who reached setup, grouped by their CLI and copied AI instruction activity within 24 hours')
expect(messages['frontend-onboarding-setup-cli-only']).toBe('Started CLI')
expect(messages['frontend-onboarding-setup-cli-and-ai']).toBe('Started CLI + AI instructions')
expect(messages['frontend-onboarding-setup-no-cli']).toBe('Didn\'t start CLI')
expect(messages['frontend-onboarding-people']).toBe('people')
expect(messages['frontend-onboarding-daily-setup-cli-outcomes-v2-v3']).toBe('Daily Setup → CLI outcomes (v2 and v3)')
+ expect(messages['frontend-onboarding-daily-setup-cli-outcomes-v2-v4']).toBe('Daily Setup → CLI outcomes (v2–v4)')
expect(messages['frontend-onboarding-daily-setup-cli-outcomes-description']).toBe('Each person is counted once per UTC day. Left: first-time Setup views; right: returning views. Actions are attributed for up to 24 hours.')
expect(messages['frontend-onboarding-daily-setup-cli-first-time']).toBe('First-time')
expect(messages['frontend-onboarding-daily-setup-cli-returning']).toBe('Returning')
@@ -818,6 +888,9 @@ describe('admin frontend onboarding dashboard', () => {
expect(messages['frontend-onboarding-graph-organization-invite-opened']).toBe('Invitation opened')
expect(messages['frontend-onboarding-graph-organization-invite-succeeded']).toBe('Invitation succeeded')
expect(messages['frontend-onboarding-graph-organization-invite-continued']).toBe('Invitation step continued')
+ expect(messages['frontend-onboarding-graph-app-creation-started']).toBe('App creation started')
+ expect(messages['frontend-onboarding-graph-app-creation-succeeded']).toBe('App creation succeeded')
+ expect(messages['frontend-onboarding-graph-app-creation-failed']).toBe('App creation failed')
expect(messages['frontend-onboarding-graph-technical-invite-opened']).toBe('Technical invite opened')
expect(messages['frontend-onboarding-graph-technical-invite-succeeded']).toBe('Technical invite succeeded')
expect(messages['frontend-onboarding-graph-percent-of-level']).toBe('{percent}% of {level}')
diff --git a/tests/app-onboarding-apikey-loading.unit.test.ts b/tests/app-onboarding-apikey-loading.unit.test.ts
index af259f6caa..f5f13f23b0 100644
--- a/tests/app-onboarding-apikey-loading.unit.test.ts
+++ b/tests/app-onboarding-apikey-loading.unit.test.ts
@@ -6,7 +6,7 @@ const englishMessages = JSON.parse(readFileSync(new URL('../messages/en.json', i
describe('app onboarding API key loading state', () => {
it.concurrent('does not render the terminal alternative before an organization exists', () => {
- expect(onboardingSource).toContain('')
+ expect(onboardingSource).toContain('
')
})
it.concurrent('replaces every incomplete CLI command with the shared loading treatment', () => {
diff --git a/tests/app-onboarding-file-input.unit.test.ts b/tests/app-onboarding-file-input.unit.test.ts
index 4112e7286c..857918274b 100644
--- a/tests/app-onboarding-file-input.unit.test.ts
+++ b/tests/app-onboarding-file-input.unit.test.ts
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
-import { createSSRApp, h } from 'vue'
+import { createApp, createSSRApp, h, nextTick, ref } from 'vue'
import { renderToString } from 'vue/server-renderer'
import AppOnboardingIconInput from '../src/components/dashboard/AppOnboardingIconInput.vue'
@@ -52,4 +52,45 @@ describe('app onboarding file input', () => {
expect(new Set(inputIds).size).toBe(2)
expect(labelTargets).toEqual(inputIds)
})
+
+ it('clears the selected filename and native input when the model is reset', async () => {
+ const selectedIcon = ref
(null)
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const app = createApp({
+ setup: () => () => h(AppOnboardingIconInput, {
+ chooseLabel: 'Choose file',
+ emptyLabel: 'No file selected',
+ label: 'App icon',
+ modelValue: selectedIcon.value,
+ 'onUpdate:modelValue': (value: unknown) => {
+ selectedIcon.value = value
+ },
+ }),
+ })
+
+ try {
+ app.mount(container)
+ const input = container.querySelector('input') as HTMLInputElement
+ const file = new File(['icon'], 'icon.png', { type: 'image/png' })
+ Object.defineProperty(input, 'files', { configurable: true, value: [file] })
+ Object.defineProperty(input, 'value', { configurable: true, value: 'C:\\fakepath\\icon.png', writable: true })
+ input.dispatchEvent(new Event('change'))
+ await nextTick()
+
+ expect((selectedIcon.value as File).name).toBe('icon.png')
+ expect((selectedIcon.value as File).type).toBe('image/png')
+ expect(container.textContent).toContain('icon.png')
+
+ selectedIcon.value = null
+ await nextTick()
+
+ expect(container.textContent).toContain('No file selected')
+ expect(input.value).toBe('')
+ }
+ finally {
+ app.unmount()
+ container.remove()
+ }
+ })
})
diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts
index 5f817785f1..49ebcf2886 100644
--- a/tests/app-onboarding-progress-integration.unit.test.ts
+++ b/tests/app-onboarding-progress-integration.unit.test.ts
@@ -25,18 +25,27 @@ function expectSourceOrder(source: string, markers: string[]) {
describe('app onboarding progress analytics integration', () => {
it.concurrent('initializes tracking once the real initial or resumed step is resolved', () => {
- expect(onboardingSource).toContain(`import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics'`)
+ const analyticsImport = sourceBetween(
+ 'import {\n createOnboardingDetailsFieldDebouncer,',
+ 'import { createOnboardingProgressPersistence',
+ )
+ expect(analyticsImport).toContain('createOnboardingProgressTracker,')
+ expect(analyticsImport).toContain('createOnboardingTelemetryIdentity,')
+ expect(analyticsImport).toContain("} from '~/utils/onboardingProgressAnalytics'")
const initializer = sourceBetween('function initializeProgressTracking(', 'function completeAndViewStep(')
expect(initializer).toContain(`flow: props.preOrg ? 'pre_org' : 'existing_org'`)
- expect(initializer).toContain('const trackedSteps = appOnboardingSteps.value.map(step => step.id)')
+ expect(initializer).toContain(`const initialStep: OnboardingAnalyticsStep = showPreOrgWelcome.value ? 'welcome' : analyticsStepFor(flowStep.value)`)
+ expect(initializer).toContain('const trackedSteps = appOnboardingSteps.value.flatMap')
+ expect(initializer).toContain('return Object.values(APP_DETAILS_ANALYTICS_STEPS)')
+ expect(initializer).toContain(`trackedSteps.unshift('welcome')`)
expect(initializer).toContain(`if (!props.preOrg && resumed && flowStep.value === 'setup')`)
expect(initializer).toContain(`trackedSteps.push('setup')`)
expect(initializer).toContain('steps: trackedSteps')
expect(initializer).toContain('resumed,')
expect(initializer).toContain('onboardingAttemptId: onboardingTelemetry.attemptId')
expect(initializer).toContain('onboardingRunId: onboardingTelemetry.runId')
- expect(initializer).toContain('progressTracker.viewStep(flowStep.value)')
+ expect(initializer).toContain('progressTracker.viewStep(initialStep)')
expect(initializer.match(/\.viewStep\(/g)).toHaveLength(1)
const resumeDialog = sourceBetween('async function maybeResumeSavedOnboarding()', 'function whiteCardToggleButtonClass(')
@@ -222,9 +231,9 @@ describe('app onboarding progress analytics integration', () => {
})
it.concurrent('keeps Maker+ invitations inside the organization progress step', () => {
- expect(onboardingSource).toContain("createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })")
- expect(onboardingSource).toContain("trackOrganizationEvent('onboarding_organization_invite_viewed')")
- expect(onboardingSource).toContain("completeAndViewStep('setup', { appId: createdApp.value.app_id })")
+ expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })`)
+ expect(onboardingSource).toContain(`trackOrganizationEvent('onboarding_organization_invite_viewed')`)
+ expect(onboardingSource).toContain(`completeAndViewStep('setup', { appId: createdApp.value.app_id })`)
})
it.concurrent('keeps the unload warning scoped to unfinished pre-org onboarding', () => {
@@ -235,9 +244,9 @@ describe('app onboarding progress analytics integration', () => {
it.concurrent('tracks only successful forward transitions with approved context', () => {
const transitionHelpers = sourceBetween('function completeAndViewStep(', 'function whiteCardToggleButtonClass(')
- expect(transitionHelpers).toContain('progressTracker?.completeStep(previousStep, {')
- expect(transitionHelpers).toContain('nextStep,')
- expect(transitionHelpers).toContain('progressTracker?.viewStep(nextStep, previousStep)')
+ expect(transitionHelpers).toContain('progressTracker?.completeStep(previousAnalyticsStep, {')
+ expect(transitionHelpers).toContain('nextStep: nextAnalyticsStep,')
+ expect(transitionHelpers).toContain('progressTracker?.viewStep(nextAnalyticsStep, previousAnalyticsStep)')
expect(transitionHelpers).toContain('void persistOnboardingProgress()')
const intentTransition = sourceBetween('function continueFromIntent()', 'function continuePreOrgDetails()')
@@ -258,9 +267,9 @@ describe('app onboarding progress analytics integration', () => {
})
it.concurrent('reports back navigation as a new view without completing the abandoned step', () => {
- const backNavigation = sourceBetween('function viewPreviousStep(', 'function whiteCardToggleButtonClass(')
+ const backNavigation = sourceBetween('function viewPreviousStep(', 'function snapshotOnboardingProgress(')
expect(backNavigation).not.toContain('completeStep')
- expect(backNavigation).toContain('progressTracker?.viewStep(nextStep, previousStep)')
+ expect(backNavigation).toContain('progressTracker?.viewStep(nextAnalyticsStep, previousAnalyticsStep)')
expect(onboardingSource).toContain('@click="viewPreviousStep(\'choice\')"')
expect(onboardingSource).toContain('@click="viewPreviousStep(\'details\')"')
expect(onboardingSource).toContain(`props.preOrg ? viewPreviousStep('intent') : router.push('/apps')`)
diff --git a/tests/app-onboarding-v3.unit.test.ts b/tests/app-onboarding-v3.unit.test.ts
index 7e080bb44c..fbbc3eb27f 100644
--- a/tests/app-onboarding-v3.unit.test.ts
+++ b/tests/app-onboarding-v3.unit.test.ts
@@ -4,36 +4,178 @@ import { describe, expect, it } from 'vitest'
const onboardingSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingFlow.vue', import.meta.url), 'utf8')
const iconInputSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingIconInput.vue', import.meta.url), 'utf8')
+function sliceBetween(source: string, startMarker: string, endMarker: string) {
+ const start = source.indexOf(startMarker)
+ const end = source.indexOf(endMarker)
+ expect(start, `missing marker: ${startMarker}`).toBeGreaterThan(-1)
+ expect(end, `missing or misplaced marker: ${endMarker}`).toBeGreaterThan(start)
+ return source.slice(start, end)
+}
+
describe('pre-organization onboarding v3', () => {
- it.concurrent('keeps store import optional and inline for pre-organization onboarding', () => {
- expect(onboardingSource).toContain('data-test="app-onboarding-toggle-store-import"')
- expect(onboardingSource).toContain(':aria-expanded="existingAppSetup === \'import\'"')
+ it.concurrent('splits app details into name, App ID, and icon screens', () => {
+ expect(onboardingSource).toContain("type AppDetailsStep = 'name' | 'app_id' | 'icon'")
+ expect(onboardingSource).toContain("const appDetailsStep = ref('name')")
+ expect(onboardingSource).toContain('const hasProvidedAppId = computed(() => Boolean(manualAppId.value.trim() || importedStoreAppId.value.trim()))')
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('app_id')")
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('icon')")
+ expect(onboardingSource).toContain(":data-test=\"appDetailsStep === 'app_id' && !hasProvidedAppId ? 'app-onboarding-skip-app-id' : 'app-onboarding-continue'\"")
+ expect(onboardingSource).toContain('function skipAppId()')
+ expect(onboardingSource).toContain('function continueFromCurrentAppDetailsStep()')
+ expect(onboardingSource).toContain('manualAppId.value = \'\'')
+ expect(onboardingSource).toContain('continueFromAppId()')
+ expect(onboardingSource).not.toContain('@click="skipAppId"')
+ expect(onboardingSource).toContain('')
+ })
+
+ it.concurrent('validates the generated or entered App ID before either action advances', () => {
+ const appIdContinuation = sliceBetween(onboardingSource, 'function continueFromAppId()', 'function viewPreviousAppDetailsStep()')
+ expect(appIdContinuation).toContain('if (!ensureValidAppId())')
+ expect(appIdContinuation).toContain('function skipAppId()')
+ expect(appIdContinuation).toContain('continueFromAppId()')
+ expect(onboardingSource).not.toContain('app/availability')
+ })
+
+ it.concurrent('keeps the full store import on the App ID screen', () => {
expect(onboardingSource).toContain('id="app-onboarding-v2-store-url"')
- expect(onboardingSource).toContain('// Store publication is unrelated to whether the user already has a mobile project.')
+ expect(onboardingSource).toContain("const isStoreImportOpen = ref(false)")
+ expect(onboardingSource).toContain('data-test="app-onboarding-toggle-store-import"')
+ expect(onboardingSource).toContain('v-if="appDetailsStep === \'app_id\' && (props.preOrg || existingApp === true)"')
+ expect(onboardingSource).toContain(':aria-expanded="isStoreImportOpen"')
+ expect(onboardingSource).toContain('v-if="isStoreImportOpen" id="app-onboarding-store-import-panel"')
+ expect(onboardingSource).toContain("trackDetailsEvent(isStoreImportOpen.value ? 'onboarding_store_import_shown' : 'onboarding_store_import_hidden')")
expect(onboardingSource).toContain('existingApp.value = true')
- expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_shown')")
- expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_hidden')")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_submitted'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_succeeded'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_import_failed'")
expect(onboardingSource).not.toContain("{ store_url: requestedUrl }")
- const storeImport = onboardingSource.slice(
- onboardingSource.indexOf('data-test="app-onboarding-toggle-store-import"'),
- onboardingSource.indexOf('
")
+ expect(storeImport).toContain('class="d-btn min-h-11 w-full sm:w-auto"')
+ })
+
+ it.concurrent('renders the generated App ID as code without swallowing sentence punctuation', () => {
+ expect(onboardingSource).toContain('keypath="app-onboarding-app-id-generated-helper"')
+ expect(onboardingSource).toContain('')
+ expect(onboardingSource).toContain('')
+ expect(iconImportPanel).toContain('class="d-btn min-h-11 w-full sm:w-auto"')
+ expect(iconImportPanel).not.toContain('sm:flex-row')
+ expect(onboardingSource).not.toContain('v-if="false"')
+ expect(onboardingSource).not.toContain('v-if="false && storeScreenshotPreview"')
+ })
+
+ it.concurrent('keeps the existing-app choice reachable for existing-organization creation', () => {
+ expect(onboardingSource).toContain('v-if="!props.preOrg && appDetailsStep === \'name\'" class="grid gap-3 sm:grid-cols-2"')
+ expect(onboardingSource).toContain('existingApp.value = props.preOrg ? true : null')
+ expect(onboardingSource).toContain("toast.error(t('app-onboarding-toast-existing-required'))")
+ const appChoice = sliceBetween(onboardingSource, 'v-if="!props.preOrg && appDetailsStep === \'name\'"', '')
+ expect(appChoice.match(/class="d-btn group h-auto min-h-32/g)).toHaveLength(2)
+ })
+
+ it.concurrent('does not restore a skipped generated App ID as a manual choice', () => {
+ expect(onboardingSource).toContain("appId: selectedAppIdSource.value === 'generated' ? '' : generatedAppId.value")
+ })
+
+ it.concurrent('tracks every app-details page as a standard onboarding step', () => {
+ expect(onboardingSource).toContain("type AppDetailsAnalyticsStep = 'app_name' | 'app_id' | 'app_icon'")
+ expect(onboardingSource).toContain("name: 'app_name'")
+ expect(onboardingSource).toContain("app_id: 'app_id'")
+ expect(onboardingSource).toContain("icon: 'app_icon'")
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('app_id'")
+ expect(onboardingSource).toContain("completeAndViewAppDetailsStep('icon'")
+ expect(onboardingSource).not.toContain('onboarding_app_details_step_viewed')
+ expect(onboardingSource).not.toContain('onboarding_app_details_step_completed')
+ })
+
+ it.concurrent('tracks app creation outcomes after the page-level funnel', () => {
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_started'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_succeeded'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_creation_failed'")
+ expect(onboardingSource).toContain('app_id_source: creationAppIdSource')
+ expect(onboardingSource).toContain('icon_source: creationIconSource')
+ expect(onboardingSource).toContain('used_fallback: createResult.wasRetried')
+ })
+
+ it.concurrent('classifies restored draft icon data as a file in creation telemetry', () => {
+ const iconSource = sliceBetween(onboardingSource, 'const selectedAppIconSource = computed', 'function createAiHelpPrompt()')
+ expect(iconSource).toContain('resolveOnboardingAppIconSource({')
+ expect(iconSource).toContain('hasSelectedIconFile: Boolean(selectedIconFile.value)')
+ expect(iconSource).toContain('localIconPreview: localIconPreview.value')
+ })
+
+ it.concurrent('tracks icon-only store import interactions and outcomes', () => {
+ expect(onboardingSource).toContain("'onboarding_store_icon_import_shown' : 'onboarding_store_icon_import_hidden'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_submitted'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_succeeded'")
+ expect(onboardingSource).toContain("trackDetailsEvent('onboarding_store_icon_import_failed'")
+ const resetStoreImportState = sliceBetween(onboardingSource, 'function resetStoreImportState()', 'let resumeIconLoadRun')
+ expect(resetStoreImportState).toContain('cancelPendingStoreIconImport()')
+ expect(resetStoreImportState).toContain("iconStoreUrl.value = ''")
+ expect(resetStoreImportState).toContain('isStoreImportOpen.value = false')
+ expect(resetStoreImportState).toContain('isStoreIconImportOpen.value = false')
+ })
+
+ it.concurrent('invalidates the opposite import before either store request starts', () => {
+ const metadataImport = sliceBetween(onboardingSource, 'async function importStoreMetadata()', 'function cancelPendingStoreIconImport()')
+ expect(metadataImport).toContain('cancelPendingStoreIconImport()')
+ expect(metadataImport.indexOf('cancelPendingStoreIconImport()')).toBeLessThan(metadataImport.indexOf('const requestedRun = ++storeImportRun'))
+
+ const iconImport = sliceBetween(onboardingSource, 'async function importStoreIcon()', 'function toggleStoreImport()')
+ expect(iconImport).toContain('cancelPendingStoreImport()')
+ expect(iconImport.indexOf('cancelPendingStoreImport()')).toBeLessThan(iconImport.indexOf('const requestedRun = ++storeIconImportRun'))
+ })
+
+ it.concurrent('blocks app-details navigation while either store import is pending', () => {
+ expect(onboardingSource).toContain('const isAppDetailsNavigationPending = computed(() => (')
+ expect(onboardingSource).toContain('isSubmitting.value || isImportingStore.value || isImportingStoreIcon.value')
+ expect(onboardingSource.match(/:disabled="isAppDetailsNavigationPending"/g)).toHaveLength(2)
+ })
+
+ it.concurrent('continues to organization creation after the pre-org icon step', () => {
+ expect(onboardingSource).toContain("t('app-onboarding-continue')")
+ expect(onboardingSource).toContain("t('app-onboarding-finish-details')")
})
it.concurrent('does not send raw onboarding field values to analytics', () => {
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_name_entered', 'app_name'")
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_id_entered', 'app_id'")
- expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_url_entered', 'store_url'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_id_entered', 'app_id', 'app_id'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_url_entered', 'store_url', 'app_id'")
+ expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_icon_url_entered', 'icon_store_url', 'app_icon'")
+ expect(onboardingSource).toContain('@input="onIconStoreUrlInput"')
expect(onboardingSource).not.toContain('{ app_name:')
expect(onboardingSource).not.toContain('{ store_url:')
})
+ it.concurrent('maps the visual details section to page-level analytics steps', () => {
+ expect(onboardingSource).toContain('function analyticsStepFor(')
+ expect(onboardingSource).toContain('return APP_DETAILS_ANALYTICS_STEPS[detailsStep]')
+ expect(onboardingSource).toContain(`const initialStep: OnboardingAnalyticsStep = showPreOrgWelcome.value ? 'welcome' : analyticsStepFor(flowStep.value)`)
+ expect(onboardingSource).toContain('progressTracker.viewStep(initialStep)')
+ expect(onboardingSource).toContain('const previousAnalyticsStep = analyticsStepFor(previousStep)')
+ expect(onboardingSource).toContain('const nextAnalyticsStep = analyticsStepFor(nextStep)')
+ })
+
it.concurrent('tracks native app icon picker and upload outcomes', () => {
expect(iconInputSource).toContain("emit('pickerOpened')")
expect(iconInputSource).toContain("emit('pickerOpenFailed')")
@@ -42,13 +184,18 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_icon_uploaded'")
expect(onboardingSource).toContain("trackDetailsEvent('onboarding_app_icon_upload_failed'")
expect(onboardingSource).toContain("parsedIconUrl.protocol === 'data:' && iconSourceUrl.startsWith('data:image/')")
+ expect(onboardingSource).toContain("const restoredLocalIconSource = localIconPreview.value.startsWith('data:image/')")
+ expect(onboardingSource).toContain('await uploadIcon(appId, restoredLocalIconSource || importedIconSource)')
})
it.concurrent('opens App ID guidance and tracks only the open action', () => {
- const appIdHelp = onboardingSource.slice(onboardingSource.indexOf('function openAppIdHelp()'), onboardingSource.indexOf('function applyAppIdSuggestion('))
+ const appIdHelp = sliceBetween(onboardingSource, 'function openAppIdHelp()', 'function applyAppIdSuggestion(')
expect(appIdHelp).toContain("trackDetailsEvent('onboarding_app_id_help_opened')")
expect(appIdHelp).toContain('dialogStore.openDialog({')
expect(appIdHelp).not.toContain('onDialogDismiss')
+ expect(onboardingSource).toContain('class="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm leading-6')
+ expect(onboardingSource).toContain('keypath="app-onboarding-app-id-generated-helper" tag="span"')
+ expect(onboardingSource).toContain("t('app-onboarding-app-id-learn-more')")
})
it.concurrent('imports organization details and branches to invitations above Solo', () => {
@@ -69,10 +216,7 @@ describe('pre-organization onboarding v3', () => {
expect(onboardingSource).toContain("organizationWebsiteInput.value = ''")
expect(onboardingSource).toContain("t('organization-onboarding-imported-logo-label')")
expect(onboardingSource).toContain("t('organization-onboarding-website-import-success')")
- const deletion = onboardingSource.slice(
- onboardingSource.indexOf('function deleteImportedOrganizationDetails()'),
- onboardingSource.indexOf('async function uploadImportedOrganizationLogo('),
- )
+ const deletion = sliceBetween(onboardingSource, 'function deleteImportedOrganizationDetails()', 'async function uploadImportedOrganizationLogo(')
expect(deletion).not.toContain('orgNameInput')
})
@@ -87,22 +231,35 @@ describe('pre-organization onboarding v3', () => {
it.concurrent('creates both records before exposing invitations', () => {
expect(onboardingSource).toContain('async function createOrganizationAndApp()')
expect(onboardingSource).toContain('async function createAppRecord(')
- const creation = onboardingSource.slice(
- onboardingSource.indexOf('async function createOrganizationAndApp()'),
- onboardingSource.indexOf('async function createAppRecord('),
- )
+ const creation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(')
expect(creation).toContain('await createAppRecord(')
expect(creation).toContain('showOrganizationInvite.value = shouldInvite')
expect(creation.indexOf('await createAppRecord(')).toBeLessThan(creation.indexOf('showOrganizationInvite.value = shouldInvite'))
})
+ it.concurrent('retries only app creation after an App ID conflict created the organization', () => {
+ const finishDetails = sliceBetween(onboardingSource, 'function finishAppDetails()', 'function returnToAppIdAfterConflict()')
+ expect(finishDetails).toContain('preOrgCreatedOrganizationId.value')
+ expect(finishDetails).toContain('completePreOrgAppCreation(preOrgCreatedOrganizationId.value, preOrgShouldInvite.value)')
+
+ const conflictRecovery = sliceBetween(onboardingSource, 'function returnToAppIdAfterConflict()', 'async function uploadIcon(')
+ expect(conflictRecovery).toContain("flowStep.value = 'details'")
+ expect(conflictRecovery).toContain("appDetailsStep.value = 'app_id'")
+ expect(conflictRecovery).toContain("progressTracker?.viewStep('app_id', previousAnalyticsStep)")
+
+ const organizationCreation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(')
+ expect(organizationCreation).toContain('preOrgCreatedOrganizationId.value = data.id')
+ expect(organizationCreation).toContain('await completePreOrgAppCreation(data.id, shouldInvite)')
+ expect(organizationCreation).toContain("await createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })")
+
+ const appCreation = sliceBetween(onboardingSource, 'async function createAppRecord(', 'async function seedDemoData()')
+ expect(appCreation).toContain('returnToAppIdAfterConflict()')
+ })
+
it.concurrent('shows technical delegation unconditionally on pre-org setup', () => {
expect(onboardingSource).toContain("flowStep === 'setup' && createdApp")
expect(onboardingSource).toContain("!props.preOrg && flowStep === 'choice'")
- const setup = onboardingSource.slice(
- onboardingSource.indexOf("flowStep === 'setup' && createdApp"),
- onboardingSource.indexOf("!props.preOrg && flowStep === 'choice'"),
- )
+ const setup = sliceBetween(onboardingSource, "flowStep === 'setup' && createdApp", "!props.preOrg && flowStep === 'choice'")
expect(setup).toContain(' {
+ it.concurrent('renders the approved welcome copy and motion affordances', async () => {
+ const source = await readFile(new URL('../src/components/dashboard/AppOnboardingWelcome.vue', import.meta.url), 'utf8')
+ const messages = JSON.parse(await readFile(new URL('../messages/en.json', import.meta.url), 'utf8')) as Record
+
+ expect(messages['onboarding-welcome-eyebrow']).toBe('Good to have you here')
+ expect(messages['onboarding-welcome-title']).toBe('Welcome to Capgo')
+ expect(messages['onboarding-welcome-description']).toBe('You’re a few steps away from the magic of over-the-air updates — and so much more.')
+ expect(messages['onboarding-welcome-cta']).toBe('Set up my first update')
+ expect(source).toContain('data-test="onboarding-welcome-continue"')
+ expect(source).toContain('data-test="onboarding-welcome-brand"')
+ expect(source).toContain('src="/favicon.svg"')
+ expect(source).toContain('class="d-btn d-btn-primary')
+ const primaryButtonTag = source.slice(source.indexOf('