From dfc80faf7d0e51900e826cefbb29cbc2396f54c5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 12:58:06 +0200 Subject: [PATCH 1/9] feat(onboarding): simplify app details flow --- messages/en.context.json | 11 + messages/en.json | 11 + .../dashboard/AppOnboardingFlow.vue | 194 ++++++++++++++++-- .../dashboard/AppOnboardingIconInput.vue | 70 ++++++- src/utils/onboardingProgressAnalytics.ts | 68 +++++- tests/app-onboarding-file-input.unit.test.ts | 18 +- ...boarding-progress-integration.unit.test.ts | 3 +- tests/app-onboarding-v2.unit.test.ts | 55 +++++ ...rding-details-field-debouncer.unit.test.ts | 45 ++++ ...onboarding-progress-analytics.unit.test.ts | 27 ++- 10 files changed, 461 insertions(+), 41 deletions(-) create mode 100644 tests/app-onboarding-v2.unit.test.ts create mode 100644 tests/onboarding-details-field-debouncer.unit.test.ts diff --git a/messages/en.context.json b/messages/en.context.json index 7a4dcf8cba..f171e99d57 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -426,8 +426,10 @@ "app-onboarding-explore-dashboard-confirm-title": "Used in Capgo web console areas: components. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-exploring-subtitle": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-exploring-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-icon-choose-file": "Used in pre-organization app onboarding. Role: button that opens the native image file picker.", "app-onboarding-icon-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-icon-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-icon-no-file-selected": "Used in pre-organization app onboarding. Role: status shown before an app icon file is selected.", "app-onboarding-icon-preview-alt": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-install-badge": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-install-ready-title": "Used in app onboarding flow. Role: section or dialog title about \"app onboarding install ready title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -477,6 +479,14 @@ "app-onboarding-toast-no-organization": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-toast-resume-not-found": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-toast-store-metadata-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-v2-ai-help-status": "Used in the pre-organization app onboarding AI help prompt. Role: status clarifying that store publication is optional.", + "app-onboarding-v2-appid-dialog-description": "Used in pre-organization app onboarding. Role: dialog text explaining that Capgo's App ID may differ from native project IDs.", + "app-onboarding-v2-appid-dialog-title": "Used in pre-organization app onboarding. Role: title for the App ID explanation dialog.", + "app-onboarding-v2-appid-help": "Used in the pre-organization app onboarding flow. Role: helper text explaining which native project identifier to enter.", + "app-onboarding-v2-details-helper": "Used in the pre-organization app onboarding flow. Role: helper text clarifying that store publication is not required.", + "app-onboarding-v2-details-title": "Used in the pre-organization app onboarding flow. Role: section title for entering app details.", + "app-onboarding-v2-store-import-help": "Used in the pre-organization app onboarding flow. Role: helper text for the optional store import.", + "app-onboarding-v2-store-import-toggle": "Used in the pre-organization app onboarding flow. Role: button that reveals the optional store import controls.", "app-perm": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-to-channel": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-transferred": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1495,6 +1505,7 @@ "key-upload": "Used in Capgo web console. Role: short UI label about \"key upload\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "key-write": "Used in Capgo web console. Role: short UI label about \"key write\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "language": "Used in Capgo web console areas: pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "learn-more": "Used in pre-organization app onboarding. Role: button opening additional App ID guidance.", "last": "Used in Capgo web console. Role: short UI label about \"last\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-1-hour": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-12-hours": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 38a44399a5..af0704a5fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -408,8 +408,10 @@ "app-onboarding-existing-yes-helper": "Import details from the store listing or enter the package ID manually.", "app-onboarding-exploring-subtitle": "Demo data is temporary — your real app is waiting.", "app-onboarding-exploring-title": "Exploring the Capgo dashboard", + "app-onboarding-icon-choose-file": "Choose file", "app-onboarding-icon-help": "The icon is optional. If you import from a store listing, Capgo will try to reuse the store icon automatically.", "app-onboarding-icon-label": "App icon", + "app-onboarding-icon-no-file-selected": "No file selected", "app-onboarding-icon-preview-alt": "App icon preview", "app-onboarding-install-badge": "CLI onboarding", "app-onboarding-explore-dashboard": "Explore Capgo dashboard", @@ -462,6 +464,14 @@ "app-onboarding-toast-no-organization": "No organization selected.", "app-onboarding-toast-resume-not-found": "Unable to find the onboarding app.", "app-onboarding-toast-store-metadata-error": "Unable to fetch metadata from that store link.", + "app-onboarding-v2-ai-help-status": "Existing mobile project; store publication is optional", + "app-onboarding-v2-appid-dialog-description": "Capgo uses this App ID to identify the app in the dashboard and CLI. It is often the same as your native project ID, but it does not have to be. Changing it here does not change your iOS Bundle Identifier, Android Application ID, Capacitor appId, or store listing.", + "app-onboarding-v2-appid-dialog-title": "How does the Capgo App ID work?", + "app-onboarding-v2-appid-help": "Choose the ID Capgo should use for this app. It may differ from your native project IDs.", + "app-onboarding-v2-details-helper": "Enter the details from your project. Your app does not need to be published on the App Store or Google Play.", + "app-onboarding-v2-details-title": "Add your app details", + "app-onboarding-v2-store-import-help": "Paste a store link to prefill the app name, App ID, and icon when available.", + "app-onboarding-v2-store-import-toggle": "Import details from the App Store or Google Play", "app-not-found": "App not found", "app-not-found-description": "This app could not be found. It might have been deleted or you might not have access to it.", "app-perm": "App permission", @@ -1422,6 +1432,7 @@ "key-upload": "Upload", "key-write": "Write", "language": "Language", + "learn-more": "Learn more", "last": "Last", "last-12-months": "Last 12 Months", "last-30-minutes": "Last 30 minutes", diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index e62fd83b4a..8ae1f282ff 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1,6 +1,8 @@ diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index a78a5bc9f5..34e86561cf 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -1,10 +1,26 @@ import { pushEvent } from '~/services/posthog' -export const ONBOARDING_ANALYTICS_VERSION = 1 +export const ONBOARDING_ANALYTICS_VERSION = 2 export type OnboardingAnalyticsFlow = 'pre_org' | 'existing_org' export type OnboardingAnalyticsStep = 'intent' | 'details' | 'organization' | 'choice' | 'install' | 'setup' export type OnboardingIntent = 'ota' | 'builder' | 'both' | 'exploring' +export type OnboardingDetailsEvent + = | 'onboarding_app_id_entered' + | 'onboarding_app_id_help_opened' + | 'onboarding_app_icon_picked' + | 'onboarding_app_icon_picker_closed_without_selection' + | 'onboarding_app_icon_picker_open_failed' + | 'onboarding_app_icon_picker_opened' + | 'onboarding_app_icon_upload_failed' + | 'onboarding_app_icon_uploaded' + | 'onboarding_app_name_entered' + | 'onboarding_store_import_failed' + | 'onboarding_store_import_hidden' + | 'onboarding_store_import_shown' + | 'onboarding_store_import_submitted' + | 'onboarding_store_import_succeeded' + | 'onboarding_store_url_entered' type AnalyticsPrimitive = string | number | boolean | null type AnalyticsProperties = Record @@ -17,6 +33,47 @@ export interface OnboardingStepCompletionProperties { storeImportUsed?: boolean } +export interface OnboardingDetailsEventProperties { + app_id?: string + app_name?: string + icon_source?: 'file' | 'store' + store_url?: string +} + +export type OnboardingDetailsFieldProperty = keyof Pick + +export function createOnboardingDetailsFieldDebouncer( + emit: (name: OnboardingDetailsEvent, properties: OnboardingDetailsEventProperties) => void, + delayMs = 1_000, +) { + const timers = new Map>() + + function schedule(name: OnboardingDetailsEvent, property: OnboardingDetailsFieldProperty, value: string) { + const activeTimer = timers.get(property) + if (activeTimer) + clearTimeout(activeTimer) + + const normalizedValue = value.trim() + if (!normalizedValue) { + timers.delete(property) + return + } + + timers.set(property, setTimeout(() => { + emit(name, { [property]: normalizedValue }) + timers.delete(property) + }, delayMs)) + } + + function dispose() { + for (const timer of timers.values()) + clearTimeout(timer) + timers.clear() + } + + return { dispose, schedule } +} + interface CreateOnboardingProgressTrackerOptions { capture?: CaptureEvent flow: OnboardingAnalyticsFlow @@ -101,8 +158,17 @@ export function createOnboardingProgressTracker(options: CreateOnboardingProgres safelyCapture('onboarding_step_completed', properties) } + function trackDetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { + const properties = sharedProperties('details') + if (!properties) + return + + safelyCapture(name, { ...properties, ...details }) + } + return { completeStep, + trackDetailsEvent, viewStep, } } diff --git a/tests/app-onboarding-file-input.unit.test.ts b/tests/app-onboarding-file-input.unit.test.ts index 00c55ad8e0..b0982b4791 100644 --- a/tests/app-onboarding-file-input.unit.test.ts +++ b/tests/app-onboarding-file-input.unit.test.ts @@ -1,24 +1,26 @@ // @vitest-environment happy-dom -import { defaultConfig, plugin } from '@formkit/vue' import { describe, expect, it } from 'vitest' import { createSSRApp } from 'vue' import { renderToString } from 'vue/server-renderer' -import { rootClasses } from '../formkit.theme' import AppOnboardingIconInput from '../src/components/dashboard/AppOnboardingIconInput.vue' describe('app onboarding file input', () => { it('keeps the rendered empty file status visible in dark mode', async () => { - const app = createSSRApp(AppOnboardingIconInput, { label: 'App icon' }) - app.use(plugin, defaultConfig({ config: { rootClasses } })) + const app = createSSRApp(AppOnboardingIconInput, { + chooseLabel: 'Choose file', + emptyLabel: 'No file selected', + label: 'App icon', + }) const html = await renderToString(app) const container = document.createElement('div') container.innerHTML = html - const noFiles = container.querySelector('.formkit-noFiles') + const noFiles = Array.from(container.querySelectorAll('span')) + .find(element => element.textContent?.trim() === 'No file selected') - expect(noFiles).not.toBeNull() - expect(noFiles?.classList.contains('text-slate-600!')).toBe(true) - expect(noFiles?.classList.contains('dark:text-slate-300!')).toBe(true) + expect(noFiles).toBeDefined() + expect(noFiles?.classList.contains('text-slate-600')).toBe(true) + expect(noFiles?.classList.contains('dark:text-slate-300')).toBe(true) }) }) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index a1c7afa329..a87408e4d2 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -9,7 +9,8 @@ function sourceBetween(start: string, end: string) { describe('app onboarding progress analytics integration', () => { it.concurrent('initializes tracking once the real initial or resumed step is resolved', () => { - expect(onboardingSource).toContain("import { createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics'") + expect(onboardingSource).toContain('createOnboardingProgressTracker') + expect(onboardingSource).toContain("from '~/utils/onboardingProgressAnalytics'") const initializer = sourceBetween('function initializeProgressTracking(', 'function whiteCardToggleButtonClass(') expect(initializer).toContain("flow: props.preOrg ? 'pre_org' : 'existing_org'") diff --git a/tests/app-onboarding-v2.unit.test.ts b/tests/app-onboarding-v2.unit.test.ts new file mode 100644 index 0000000000..8ce1ab79f5 --- /dev/null +++ b/tests/app-onboarding-v2.unit.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from 'node:fs' +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 sourceBetween(start: string, end: string) { + return onboardingSource.slice(onboardingSource.indexOf(start), onboardingSource.indexOf(end)) +} + +describe('pre-organization onboarding v2 app details', () => { + it.concurrent('shows app name and App ID without asking about store publication', () => { + const detailsTemplate = sourceBetween('
', '
{ + expect(onboardingSource).toContain('data-test="app-onboarding-toggle-store-import"') + expect(onboardingSource).toContain(':aria-expanded="existingAppSetup === \'import\'"') + 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('existingApp.value = true') + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_shown')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_hidden')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_submitted'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_succeeded'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_failed'") + }) + + it.concurrent('tracks native app icon picker and upload outcomes', () => { + expect(iconInputSource).toContain("emit('pickerOpened')") + expect(iconInputSource).toContain("emit('pickerOpenFailed')") + expect(iconInputSource).toContain("emit('pickerClosedWithoutSelection')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_picked'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_uploaded'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_upload_failed'") + }) + + it.concurrent('opens App ID guidance and tracks only the open action', () => { + const appIdHelp = sourceBetween('function openAppIdHelp()', 'function applyAppIdSuggestion(') + expect(appIdHelp).toContain("trackV2DetailsEvent('onboarding_app_id_help_opened')") + expect(appIdHelp).toContain('dialogStore.openDialog({') + expect(appIdHelp).not.toContain('onDialogDismiss') + }) + + it.concurrent('preserves the existing-organization publication choices', () => { + expect(onboardingSource).toContain('data-test="app-onboarding-existing-yes"') + expect(onboardingSource).toContain('data-test="app-onboarding-existing-no"') + expect(onboardingSource).toContain('v-if="!props.preOrg && existingApp === true"') + }) +}) diff --git a/tests/onboarding-details-field-debouncer.unit.test.ts b/tests/onboarding-details-field-debouncer.unit.test.ts new file mode 100644 index 0000000000..f8b289973c --- /dev/null +++ b/tests/onboarding-details-field-debouncer.unit.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOnboardingDetailsFieldDebouncer } from '../src/utils/onboardingProgressAnalytics' + +describe('onboarding details field analytics debounce', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('emits the latest non-empty value one second after the last keystroke', () => { + vi.useFakeTimers() + const emit = vi.fn() + const tracker = createOnboardingDetailsFieldDebouncer(emit) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Example') + vi.advanceTimersByTime(750) + tracker.schedule('onboarding_app_name_entered', 'app_name', ' Example App ') + vi.advanceTimersByTime(999) + expect(emit).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(emit).toHaveBeenCalledOnce() + expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { app_name: 'Example App' }) + }) + + it('tracks fields independently and cancels pending empty or disposed values', () => { + vi.useFakeTimers() + const emit = vi.fn() + const tracker = createOnboardingDetailsFieldDebouncer(emit) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Discard me') + tracker.schedule('onboarding_app_name_entered', 'app_name', ' ') + tracker.schedule('onboarding_app_id_entered', 'app_id', 'com.example.app') + tracker.schedule('onboarding_store_url_entered', 'store_url', 'https://apps.apple.com/example') + vi.advanceTimersByTime(1_000) + + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { app_id: 'com.example.app' }) + expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { store_url: 'https://apps.apple.com/example' }) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Never emitted') + tracker.dispose() + vi.runAllTimers() + expect(emit).toHaveBeenCalledTimes(2) + }) +}) diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index b101c39f4d..f240ea4f6d 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -20,7 +20,7 @@ describe('onboarding progress analytics', () => { tracker.viewStep('intent') - expect(ONBOARDING_ANALYTICS_VERSION).toBe(1) + expect(ONBOARDING_ANALYTICS_VERSION).toBe(2) expect(capture).toHaveBeenCalledOnce() expect(capture).toHaveBeenCalledWith( 'onboarding_step_viewed', @@ -112,6 +112,31 @@ describe('onboarding progress analytics', () => { }) }) + it.concurrent('associates app-details interaction events with the active onboarding attempt', () => { + const capture = vi.fn() + const tracker = createOnboardingProgressTracker({ + capture, + flow: 'pre_org', + resumed: false, + steps, + supaHost: 'https://supabase.capgo.test', + }) + + tracker.trackDetailsEvent('onboarding_app_name_entered', { app_name: 'Example App' }) + + expect(capture).toHaveBeenCalledWith( + 'onboarding_app_name_entered', + 'https://supabase.capgo.test', + expect.objectContaining({ + app_name: 'Example App', + flow: 'pre_org', + onboarding_attempt_id: expect.any(String), + onboarding_version: 2, + step: 'details', + }), + ) + }) + it.concurrent('deduplicates completion for one visit and resets timing after back navigation', () => { let now = 10 const capture = vi.fn() From 57b61f8e790460902b70bacf989aedce9a68ab02 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 13:05:54 +0200 Subject: [PATCH 2/9] test(onboarding): update registration flow for v2 --- playwright/e2e/register.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts index 68d3ec6d30..f679d42629 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -38,7 +38,6 @@ test.describe('Registration', () => { await page.click('[data-test="onboarding-intent-ota"]') await page.click('[data-test="app-onboarding-continue-intent"]') - await page.click('[data-test="app-onboarding-existing-no"]') await page.fill('[data-test="app-onboarding-name"]', appName) await page.click('[data-test="app-onboarding-continue"]') From 1cf6aa8d2fd23dd17d26886d651673f48ecfe55b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 13:10:15 +0200 Subject: [PATCH 3/9] fix(onboarding): avoid raw values in analytics --- .../dashboard/AppOnboardingFlow.vue | 6 +++--- src/utils/onboardingProgressAnalytics.ts | 20 +++++++++---------- tests/app-onboarding-v2.unit.test.ts | 9 +++++++++ ...rding-details-field-debouncer.unit.test.ts | 6 +++--- ...onboarding-progress-analytics.unit.test.ts | 4 ++-- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 8ae1f282ff..c9d1be43c3 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -477,7 +477,7 @@ async function importStoreMetadata() { if (!requestedUrl || existingAppSetup.value !== 'import') return - trackV2DetailsEvent('onboarding_store_import_submitted', { store_url: requestedUrl }) + trackV2DetailsEvent('onboarding_store_import_submitted') const requestedRun = ++storeImportRun isImportingStore.value = true try { @@ -512,14 +512,14 @@ async function importStoreMetadata() { if (props.preOrg) existingApp.value = true - trackV2DetailsEvent('onboarding_store_import_succeeded', { store_url: requestedUrl }) + trackV2DetailsEvent('onboarding_store_import_succeeded') } catch (error) { if (requestedRun !== storeImportRun || existingAppSetup.value !== 'import' || storeUrl.value.trim() !== requestedUrl) return console.error('Cannot import store metadata', error) - trackV2DetailsEvent('onboarding_store_import_failed', { store_url: requestedUrl }) + trackV2DetailsEvent('onboarding_store_import_failed') toast.error(t('app-onboarding-toast-store-metadata-error')) } finally { diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index 34e86561cf..9173a26621 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -34,34 +34,32 @@ export interface OnboardingStepCompletionProperties { } export interface OnboardingDetailsEventProperties { - app_id?: string - app_name?: string + field_length?: number icon_source?: 'file' | 'store' - store_url?: string } -export type OnboardingDetailsFieldProperty = keyof Pick +export type OnboardingDetailsField = 'app_id' | 'app_name' | 'store_url' export function createOnboardingDetailsFieldDebouncer( emit: (name: OnboardingDetailsEvent, properties: OnboardingDetailsEventProperties) => void, delayMs = 1_000, ) { - const timers = new Map>() + const timers = new Map>() - function schedule(name: OnboardingDetailsEvent, property: OnboardingDetailsFieldProperty, value: string) { - const activeTimer = timers.get(property) + function schedule(name: OnboardingDetailsEvent, field: OnboardingDetailsField, value: string) { + const activeTimer = timers.get(field) if (activeTimer) clearTimeout(activeTimer) const normalizedValue = value.trim() if (!normalizedValue) { - timers.delete(property) + timers.delete(field) return } - timers.set(property, setTimeout(() => { - emit(name, { [property]: normalizedValue }) - timers.delete(property) + timers.set(field, setTimeout(() => { + emit(name, { field_length: normalizedValue.length }) + timers.delete(field) }, delayMs)) } diff --git a/tests/app-onboarding-v2.unit.test.ts b/tests/app-onboarding-v2.unit.test.ts index 8ce1ab79f5..f5294d2e8f 100644 --- a/tests/app-onboarding-v2.unit.test.ts +++ b/tests/app-onboarding-v2.unit.test.ts @@ -29,6 +29,15 @@ describe('pre-organization onboarding v2 app details', () => { expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_submitted'") expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_succeeded'") expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_failed'") + expect(onboardingSource).not.toContain("{ store_url: requestedUrl }") + }) + + 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).not.toContain('{ app_name:') + expect(onboardingSource).not.toContain('{ store_url:') }) it.concurrent('tracks native app icon picker and upload outcomes', () => { diff --git a/tests/onboarding-details-field-debouncer.unit.test.ts b/tests/onboarding-details-field-debouncer.unit.test.ts index f8b289973c..629ac2a17e 100644 --- a/tests/onboarding-details-field-debouncer.unit.test.ts +++ b/tests/onboarding-details-field-debouncer.unit.test.ts @@ -19,7 +19,7 @@ describe('onboarding details field analytics debounce', () => { vi.advanceTimersByTime(1) expect(emit).toHaveBeenCalledOnce() - expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { app_name: 'Example App' }) + expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { field_length: 11 }) }) it('tracks fields independently and cancels pending empty or disposed values', () => { @@ -34,8 +34,8 @@ describe('onboarding details field analytics debounce', () => { vi.advanceTimersByTime(1_000) expect(emit).toHaveBeenCalledTimes(2) - expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { app_id: 'com.example.app' }) - expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { store_url: 'https://apps.apple.com/example' }) + expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { field_length: 15 }) + expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { field_length: 30 }) tracker.schedule('onboarding_app_name_entered', 'app_name', 'Never emitted') tracker.dispose() diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index f240ea4f6d..3a17d72378 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -122,13 +122,13 @@ describe('onboarding progress analytics', () => { supaHost: 'https://supabase.capgo.test', }) - tracker.trackDetailsEvent('onboarding_app_name_entered', { app_name: 'Example App' }) + tracker.trackDetailsEvent('onboarding_app_name_entered', { field_length: 11 }) expect(capture).toHaveBeenCalledWith( 'onboarding_app_name_entered', 'https://supabase.capgo.test', expect.objectContaining({ - app_name: 'Example App', + field_length: 11, flow: 'pre_org', onboarding_attempt_id: expect.any(String), onboarding_version: 2, From 7750a183d69d3d51681353be871682ea35160617 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 13:18:54 +0200 Subject: [PATCH 4/9] test(onboarding): select organization scale --- playwright/e2e/register.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts index f679d42629..3fd2a914dc 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -44,6 +44,7 @@ test.describe('Registration', () => { await expectProtectedRouteRedirect(page, '/apps', /\/onboarding\/app/, '[data-test="onboarding-logout"]') await expect(page.locator('[data-test="onboarding-org-name"]')).toHaveValue(appName) + await page.locator('[data-test="onboarding-estimated-users-option"]').first().click() await page.getByRole('button', { name: 'Back', exact: true }).click() await page.fill('[data-test="app-onboarding-name"]', editedAppName) await page.click('[data-test="app-onboarding-continue"]') From c86f7276d3872d81049271778ed2a098b56eb473 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Wed, 12 Aug 2026 13:28:02 +0200 Subject: [PATCH 5/9] fix(onboarding): satisfy frontend quality checks --- src/components/dashboard/AppOnboardingFlow.vue | 12 ++++++------ src/components/dashboard/AppOnboardingIconInput.vue | 5 +++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index c9d1be43c3..54c58c5ef4 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -192,11 +192,11 @@ const generatedAppId = computed(() => createdApp.value?.app_id || manualAppId.va const aiHelpPrompt = computed(() => { const resolvedAppId = createdApp.value?.app_id || generatedAppId.value || '[APP_ID]' const resolvedAppName = createdApp.value?.name?.trim() || appName.value.trim() || resolvedAppId - const appStatus = props.preOrg - ? t('app-onboarding-v2-ai-help-status') - : createdApp.value?.existing_app - ? t('app-onboarding-ai-help-status-existing') - : t('app-onboarding-ai-help-status-new') + let appStatus = t('app-onboarding-ai-help-status-new') + if (props.preOrg) + appStatus = t('app-onboarding-v2-ai-help-status') + else if (createdApp.value?.existing_app) + appStatus = t('app-onboarding-ai-help-status-existing') return t('app-onboarding-ai-help-prompt', { appName: resolvedAppName, @@ -1388,7 +1388,7 @@ watch(appName, (value) => { type="url" @input="onStoreUrlInput" > -