diff --git a/messages/en.context.json b/messages/en.context.json
index b6585d022e..bb705f2a91 100644
--- a/messages/en.context.json
+++ b/messages/en.context.json
@@ -1656,7 +1656,6 @@
"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.",
@@ -3044,5 +3043,26 @@
"your-api-key": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"your-role-in-org": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"your-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
- "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged."
+ "zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
+ "app-onboarding-icon-step-helper": "Used in app onboarding details wizard. Role: helper text.",
+ "app-onboarding-icon-step-title": "Used in app onboarding details wizard. Role: step title.",
+ "app-onboarding-import-icon-only-button": "Used in app onboarding details wizard. Role: button label.",
+ "app-onboarding-import-icon-only-helper": "Used in app onboarding details wizard. Role: helper text explaining that store re-import changes only the icon.",
+ "app-onboarding-import-icon-only-title": "Used in app onboarding details wizard. Role: section title.",
+ "app-onboarding-imported-icon-alt": "Used in app onboarding details wizard. Role: image alternative text.",
+ "app-onboarding-imported-icon-helper": "Used in app onboarding details wizard. Role: helper text.",
+ "app-onboarding-imported-icon-label": "Used in app onboarding details wizard. Role: status label.",
+ "app-onboarding-imported-icon-selected": "Used in app onboarding details wizard. Role: selected button label.",
+ "app-onboarding-name-step-helper": "Used in app onboarding details wizard. Role: helper text.",
+ "app-onboarding-name-step-title": "Used in app onboarding details wizard. Role: step title.",
+ "app-onboarding-remove-icon": "Used in app onboarding details wizard. Role: button label.",
+ "app-onboarding-skip-app-id": "Used in app onboarding details wizard. Role: button label that accepts Capgo's generated App ID.",
+ "app-onboarding-toast-store-icon-error": "Used in app onboarding details wizard. Role: error toast.",
+ "app-onboarding-use-different-icon": "Used in app onboarding details wizard. Role: section label.",
+ "app-onboarding-use-imported-icon": "Used in app onboarding details wizard. Role: button label.",
+ "app-onboarding-app-id-generated-helper": "Used in app onboarding details wizard. Role: helper text. Preserve the {appId} placeholder.",
+ "app-onboarding-app-id-learn-more": "Used in app onboarding details wizard. Role: button opening guidance about Capgo App IDs.",
+ "app-onboarding-app-id-step-helper": "Used in app onboarding details wizard. Role: helper text.",
+ "app-onboarding-app-id-step-title": "Used in app onboarding details wizard. Role: step title.",
+ "app-onboarding-finish-details": "Used in app onboarding details wizard. Role: final creation button label."
}
diff --git a/messages/en.json b/messages/en.json
index d9c3e1b4f7..e2f200d6bd 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -449,10 +449,19 @@
"app-onboarding-exploring-subtitle": "Setup isn't finished yet. You can keep exploring or continue onboarding.",
"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-help": "Upload a square PNG, JPEG, or WebP image. The icon is optional.",
"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-icon-step-helper": "Keep the imported icon, upload something else, or continue without one.",
+ "app-onboarding-icon-step-title": "Choose an icon",
+ "app-onboarding-import-icon-only-button": "Import icon",
+ "app-onboarding-import-icon-only-helper": "Only the icon will change. Your app name and App ID stay exactly as they are.",
+ "app-onboarding-import-icon-only-title": "Import an icon from another store listing",
+ "app-onboarding-imported-icon-alt": "Imported store icon",
+ "app-onboarding-imported-icon-helper": "We found this icon in the store listing you imported.",
+ "app-onboarding-imported-icon-label": "Imported from store",
+ "app-onboarding-imported-icon-selected": "Using this icon",
"app-onboarding-install-badge": "CLI onboarding",
"app-onboarding-explore-dashboard": "Explore Capgo dashboard",
"app-onboarding-explore-dashboard-confirm-description": "You can explore the dashboard now. Your app setup will remain available when you return.",
@@ -482,6 +491,8 @@
"app-onboarding-mode-import-helper": "Use an App Store or Google Play link to prefill details.",
"app-onboarding-mode-manual": "Set up manually",
"app-onboarding-mode-manual-helper": "Type the app name and bundle or package ID yourself.",
+ "app-onboarding-name-step-helper": "Use the name people will recognize in Capgo. You can change it later.",
+ "app-onboarding-name-step-title": "What is your app called?",
"app-onboarding-name-placeholder": "Capgo demo app",
"app-onboarding-next-cleanup": "When the CLI marks onboarding as completed, the temporary onboarding data can be cleared automatically before the first real upload continues.",
"app-onboarding-next-existing": "The CLI should attach Capgo to your existing project without scaffolding a new Capacitor app.",
@@ -493,6 +504,7 @@
"app-onboarding-preview-bullet-two": "Demo data stays disposable. When the CLI completes, pending onboarding data can be cleared automatically.",
"app-onboarding-preview-label": "Preview",
"app-onboarding-preview-placeholder": "Your app",
+ "app-onboarding-remove-icon": "Remove icon",
"app-onboarding-progress-label": "Onboarding progress",
"app-onboarding-progress-count": "Step {current} of {total}",
"app-onboarding-start-question": "How do you want to start?",
@@ -505,6 +517,7 @@
"app-onboarding-store-link-label": "App Store or Google Play link",
"app-onboarding-store-link-placeholder": "https://apps.apple.com/... or https://play.google.com/store/apps/details?id=com.example.app",
"app-onboarding-store-screenshot-alt": "Store screenshot preview",
+ "app-onboarding-skip-app-id": "Skip",
"app-onboarding-subtitle": "The app is created immediately in Capgo. From there you can either connect your real project in the CLI or explore the dashboard with temporary demo data.",
"app-onboarding-summary-method": "Setup method",
"app-onboarding-summary-source": "Starting point",
@@ -522,6 +535,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-toast-store-icon-error": "Unable to find an app icon at this store link.",
+ "app-onboarding-use-different-icon": "Use something else",
+ "app-onboarding-use-imported-icon": "Use this icon",
+ "app-onboarding-app-id-generated-helper": "Leave this empty and Capgo will use {appId}.",
+ "app-onboarding-app-id-learn-more": "Learn more about Capgo App ID",
+ "app-onboarding-app-id-step-helper": "Use your bundle or package ID, import it from a store listing, or skip this step.",
+ "app-onboarding-app-id-step-title": "Set your App ID",
+ "app-onboarding-finish-details": "Create app",
"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?",
@@ -1517,7 +1538,6 @@
"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/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts
index 7aa954e70e..acf4dc47d6 100644
--- a/playwright/e2e/register.spec.ts
+++ b/playwright/e2e/register.spec.ts
@@ -24,6 +24,27 @@ async function expectProtectedRouteRedirect(page: Page, targetPath: string, expe
}
}
+async function continueFromAppNameToIcon(page: Page) {
+ await page.click('[data-test="app-onboarding-continue"]')
+ await expect(page.locator('#app-onboarding-app-id')).toBeVisible()
+ await page.click('[data-test="app-onboarding-skip-app-id"]')
+ await expect(page.locator('[data-test="app-onboarding-toggle-icon-store-import"]')).toBeVisible()
+}
+
+async function continueFromAppNameToOrganization(page: Page) {
+ await continueFromAppNameToIcon(page)
+ await page.click('[data-test="app-onboarding-continue"]')
+}
+
+async function returnFromOrganizationToAppName(page: Page) {
+ await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await expect(page.locator('[data-test="app-onboarding-toggle-icon-store-import"]')).toBeVisible()
+ await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await expect(page.locator('#app-onboarding-app-id')).toBeVisible()
+ await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await expect(page.locator('[data-test="app-onboarding-name"]')).toBeVisible()
+}
+
test.describe('Registration', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/register/')
@@ -52,22 +73,22 @@ test.describe('Registration', () => {
await expect(page.locator('[data-test="app-onboarding-existing-yes"]')).toHaveCount(0)
await expect(page.locator('[data-test="app-onboarding-existing-no"]')).toHaveCount(0)
await expect(page.locator('[data-test="app-onboarding-name"]')).toBeVisible()
- await expect(page.locator('#app-onboarding-app-id')).toBeVisible()
+ await expect(page.locator('#app-onboarding-app-id')).toHaveCount(0)
await page.fill('[data-test="app-onboarding-name"]', appName)
- await page.click('[data-test="app-onboarding-continue"]')
+ await continueFromAppNameToOrganization(page)
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"]').nth(1).click()
- await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await returnFromOrganizationToAppName(page)
await page.fill('[data-test="app-onboarding-name"]', editedAppName)
- await page.click('[data-test="app-onboarding-continue"]')
+ await continueFromAppNameToOrganization(page)
await expect(page.locator('[data-test="onboarding-org-name"]')).toHaveValue(editedAppName)
await page.fill('[data-test="onboarding-org-name"]', organizationName)
- await page.getByRole('button', { name: 'Back', exact: true }).click()
+ await returnFromOrganizationToAppName(page)
await page.fill('[data-test="app-onboarding-name"]', finalAppName)
- await page.click('[data-test="app-onboarding-continue"]')
+ await continueFromAppNameToOrganization(page)
await expect(page.locator('[data-test="onboarding-org-name"]')).toHaveValue(organizationName)
await expect(page.locator('[data-test="onboarding-create-org"]')).toBeEnabled()
await page.click('[data-test="onboarding-create-org"]')
@@ -99,6 +120,7 @@ test.describe('Registration', () => {
await page.click('[data-test="onboarding-intent-ota"]')
await page.click('[data-test="app-onboarding-continue-intent"]')
await page.fill('[data-test="app-onboarding-name"]', appName)
+ await continueFromAppNameToIcon(page)
await Promise.all([
page.waitForResponse((response) => {
if (!response.url().includes('/rest/v1/users') || response.request().method() !== 'PATCH' || !response.ok())
diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue
index 7a7f0cb562..48e8fbf371 100644
--- a/src/components/dashboard/AppOnboardingFlow.vue
+++ b/src/components/dashboard/AppOnboardingFlow.vue
@@ -23,7 +23,6 @@ import IconArrowRight from '~icons/lucide/arrow-right'
import IconCheck from '~icons/lucide/check'
import IconChevronDown from '~icons/lucide/chevron-down'
import IconChevronUp from '~icons/lucide/chevron-up'
-import IconCode from '~icons/lucide/code-2'
import IconCompass from '~icons/lucide/compass'
import IconGlobe from '~icons/lucide/globe-2'
import IconInfo from '~icons/lucide/info'
@@ -68,7 +67,12 @@ import {
loadOnboardingAppDraft,
} from '~/utils/onboardingAppDraft'
import { onboardingPrimaryButtonClass, onboardingSecondaryButtonClass } from '~/utils/onboardingButtonClasses'
-import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics'
+import {
+ createOnboardingDetailsFieldDebouncer,
+ createOnboardingProgressTracker,
+ createOnboardingTelemetryIdentity,
+ resolveOnboardingAppIconSource,
+} from '~/utils/onboardingProgressAnalytics'
import { createOnboardingProgressPersistence, shouldInitializeOnboardingProgressTracking } from '~/utils/onboardingProgressPersistence'
import { allowOnboardingDashboardExploration, ONBOARDING_DASHBOARD_EXPLORED_EVENT } from '~/utils/onboardingRedirect'
import { slugifyOnboardingSegment } from '~/utils/onboardingSlug'
@@ -110,6 +114,14 @@ type AppRow = Omit
- {{ t('app-onboarding-v2-details-helper') }} +
+ {{ appDetailsStep === 'name' + ? t('app-onboarding-name-step-helper') + : appDetailsStep === 'app_id' + ? t('app-onboarding-app-id-step-helper') + : t('app-onboarding-icon-step-helper') }}
-+ {{ appName.trim() || t('app-onboarding-preview-placeholder') }} +
++ {{ generatedAppId }} +
+- {{ t('app-onboarding-start-question') }} -
-- {{ hasImportedStoreMetadata - ? t('app-onboarding-store-imported-help') - : t('app-onboarding-store-help') }} -
-- {{ props.preOrg - ? t('app-onboarding-v2-appid-help') - : existingApp - ? t('app-onboarding-appid-help-existing') - : t('app-onboarding-appid-help-new') }} -
- +{{ suggestedAppId }}
+
+ - {{ t('app-onboarding-icon-help') }} -
-+ {{ t('app-onboarding-imported-icon-label') }} +
++ {{ storeAppNamePreview || appName }} +
++ {{ t('app-onboarding-imported-icon-helper') }} +
++ {{ t('app-onboarding-icon-help') }} +
++ {{ t('app-onboarding-import-icon-only-helper') }} +
+')
+ 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 = onboardingSource.slice(
+ onboardingSource.indexOf('v-if="!props.preOrg && appDetailsStep === \'name\'"'),
+ onboardingSource.indexOf(''),
+ )
+ 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 = onboardingSource.slice(
+ onboardingSource.indexOf('const selectedAppIconSource = computed'),
+ onboardingSource.indexOf('function createAiHelpPrompt()'),
)
- expect(storeImport).toContain(' {
+ 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 = onboardingSource.slice(onboardingSource.indexOf('function resetStoreImportState()'), onboardingSource.indexOf('let resumeIconLoadRun'))
+ expect(resetStoreImportState).toContain('cancelPendingStoreIconImport()')
+ })
+
+ it.concurrent('invalidates the opposite import before either store request starts', () => {
+ const metadataImport = onboardingSource.slice(
+ onboardingSource.indexOf('async function importStoreMetadata()'),
+ onboardingSource.indexOf('function cancelPendingStoreIconImport()'),
+ )
+ expect(metadataImport).toContain('cancelPendingStoreIconImport()')
+ expect(metadataImport.indexOf('cancelPendingStoreIconImport()')).toBeLessThan(metadataImport.indexOf('const requestedRun = ++storeImportRun'))
+
+ const iconImport = onboardingSource.slice(
+ onboardingSource.indexOf('async function importStoreIcon()'),
+ onboardingSource.indexOf('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("? props.preOrg\n ? t('app-onboarding-continue')\n : 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,6 +195,8 @@ 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', () => {
@@ -49,6 +204,9 @@ describe('pre-organization onboarding v3', () => {
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', () => {
@@ -96,6 +254,37 @@ describe('pre-organization onboarding v3', () => {
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 = onboardingSource.slice(
+ onboardingSource.indexOf('function finishAppDetails()'),
+ onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
+ )
+ expect(finishDetails).toContain('preOrgCreatedOrganizationId.value')
+ expect(finishDetails).toContain('completePreOrgAppCreation(preOrgCreatedOrganizationId.value, preOrgShouldInvite.value)')
+
+ const conflictRecovery = onboardingSource.slice(
+ onboardingSource.indexOf('function returnToAppIdAfterConflict()'),
+ onboardingSource.indexOf('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 = onboardingSource.slice(
+ onboardingSource.indexOf('async function createOrganizationAndApp()'),
+ onboardingSource.indexOf('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 = onboardingSource.slice(
+ onboardingSource.indexOf('async function createAppRecord('),
+ onboardingSource.indexOf('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'")
diff --git a/tests/frontend-onboarding-analytics.unit.test.ts b/tests/frontend-onboarding-analytics.unit.test.ts
index f11d3e492e..7b0607d2cf 100644
--- a/tests/frontend-onboarding-analytics.unit.test.ts
+++ b/tests/frontend-onboarding-analytics.unit.test.ts
@@ -48,7 +48,13 @@ describe('buildFrontendOnboardingHogql', () => {
'2026-08-04T00:00:00.789Z',
)
- expect(query).toContain("event IN ('onboarding_step_viewed', 'onboarding_ai_instructions_copied', 'onboarding_app_id_entered'")
+ expect(query).toContain("event IN ('onboarding_step_viewed', 'onboarding_ai_instructions_copied'")
+ expect(query).toContain("'onboarding_app_id_entered'")
+ expect(query).toContain("'onboarding_app_creation_succeeded'")
+ expect(query).not.toContain("'onboarding_app_details_step_completed'")
+ expect(query).not.toContain("'onboarding_app_details_step_viewed'")
+ expect(query).toContain("'onboarding_store_icon_import_succeeded'")
+ expect(query).toContain("'onboarding_store_icon_url_entered'")
expect(query).toContain("'onboarding_organization_import_opened'")
expect(query).toContain("'onboarding_organization_invite_succeeded'")
expect(query).toContain("'onboarding_technical_invite_succeeded'")
@@ -68,7 +74,7 @@ describe('buildFrontendOnboardingHogql', () => {
expect(query).toContain('event = \'Builder Onboarding Step\'')
expect(query).toContain('JSONExtractString(toString(properties), \'step\') IN (\'welcome\', \'resume-prompt\')')
expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'intent\'))')
- expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'details\'))')
+ expect(query).toContain("toUnixTimestamp64Milli(minIf(timestamp, event = 'onboarding_step_viewed' AND step IN ('details', 'app_name'))) AS details_ms")
expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'organization\'))')
expect(query).toContain('toUnixTimestamp64Milli(minIf(timestamp, event = \'onboarding_step_viewed\' AND step = \'setup\'))')
expect(query).toContain('groupUniqArrayIf(tuple(event, toUnixTimestamp64Milli(timestamp)), event IN (')
diff --git a/tests/onboarding-details-field-debouncer.unit.test.ts b/tests/onboarding-details-field-debouncer.unit.test.ts
index edccf28427..a173326847 100644
--- a/tests/onboarding-details-field-debouncer.unit.test.ts
+++ b/tests/onboarding-details-field-debouncer.unit.test.ts
@@ -11,15 +11,15 @@ describe('onboarding details field analytics debounce', () => {
const emit = vi.fn()
const tracker = createOnboardingDetailsFieldDebouncer(emit)
- tracker.schedule('onboarding_app_name_entered', 'app_name', 'Example')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Example')
vi.advanceTimersByTime(750)
- tracker.schedule('onboarding_app_name_entered', 'app_name', ' Example App ')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', ' Example App ')
vi.advanceTimersByTime(999)
expect(emit).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(emit).toHaveBeenCalledOnce()
- expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { field_length: 11 })
+ expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 11 })
})
it('tracks fields independently, cancels empty values, and flushes pending values on dispose', () => {
@@ -27,20 +27,37 @@ describe('onboarding details field analytics debounce', () => {
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')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Discard me')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', ' ')
+ tracker.schedule('onboarding_app_id_entered', 'app_id', 'app_id', 'com.example.app')
+ tracker.schedule('onboarding_store_url_entered', 'store_url', 'app_id', 'https://apps.apple.com/example')
+ const iconStoreUrl = 'https://play.google.com/example'
+ tracker.schedule('onboarding_store_icon_url_entered', 'icon_store_url', 'app_icon', iconStoreUrl)
vi.advanceTimersByTime(1_000)
- expect(emit).toHaveBeenCalledTimes(2)
- expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { field_length: 15 })
- expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { field_length: 30 })
+ expect(emit).toHaveBeenCalledTimes(3)
+ expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', 'app_id', { field_length: 15 })
+ expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', 'app_id', { field_length: 30 })
+ expect(emit).toHaveBeenCalledWith('onboarding_store_icon_url_entered', 'app_icon', { field_length: iconStoreUrl.length })
- tracker.schedule('onboarding_app_name_entered', 'app_name', 'Never emitted')
+ tracker.schedule('onboarding_app_name_entered', 'app_name', 'app_name', 'Never emitted')
tracker.dispose()
vi.runAllTimers()
- expect(emit).toHaveBeenCalledTimes(3)
- expect(emit).toHaveBeenLastCalledWith('onboarding_app_name_entered', { field_length: 13 })
+ expect(emit).toHaveBeenCalledTimes(4)
+ expect(emit).toHaveBeenLastCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 13 })
+ })
+
+ it('keeps the page step captured before a user continues during the debounce', () => {
+ vi.useFakeTimers()
+ const emit = vi.fn()
+ const tracker = createOnboardingDetailsFieldDebouncer(emit)
+ let currentPage: 'app_id' | 'app_name' = 'app_name'
+
+ tracker.schedule('onboarding_app_name_entered', 'app_name', currentPage, 'Example App')
+ currentPage = 'app_id'
+ vi.advanceTimersByTime(1_000)
+
+ expect(currentPage).toBe('app_id')
+ expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', 'app_name', { field_length: 11 })
})
})
diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts
index 6d742b8692..4af929eb1e 100644
--- a/tests/onboarding-progress-analytics.unit.test.ts
+++ b/tests/onboarding-progress-analytics.unit.test.ts
@@ -3,6 +3,7 @@ import {
createOnboardingProgressTracker,
createOnboardingTelemetryIdentity,
ONBOARDING_ANALYTICS_VERSION,
+ resolveOnboardingAppIconSource,
} from '../src/utils/onboardingProgressAnalytics'
const steps = ['intent', 'details', 'organization', 'setup'] as const
@@ -17,6 +18,26 @@ const trackerIdentity = {
}
describe('onboarding progress analytics', () => {
+ it.concurrent('classifies restored draft icon data as a file source', () => {
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: true,
+ hasSelectedIconFile: false,
+ localIconPreview: 'data:image/png;base64,restored-icon',
+ })).toBe('file')
+
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: true,
+ hasSelectedIconFile: false,
+ localIconPreview: 'https://capgo.test/restored-icon.png',
+ })).toBe('store')
+
+ expect(resolveOnboardingAppIconSource({
+ canUseStoreImportPreview: false,
+ hasSelectedIconFile: false,
+ localIconPreview: '',
+ })).toBe('none')
+ })
+
it.concurrent('keeps the resumed attempt while continuing from saved progress', () => {
const capture = vi.fn()
const ids = [ATTEMPT_A2, RUN_R2_UUID]
@@ -271,7 +292,7 @@ describe('onboarding progress analytics', () => {
supaHost: 'https://supabase.capgo.test',
})
- tracker.trackDetailsEvent('onboarding_app_name_entered', { field_length: 11 })
+ tracker.trackDetailsEvent('onboarding_app_name_entered', 'details', { field_length: 11 })
expect(capture).toHaveBeenCalledWith(
'onboarding_app_name_entered',
@@ -287,6 +308,64 @@ describe('onboarding progress analytics', () => {
)
})
+ it.concurrent('associates app-details interactions with their page-level analytics step', () => {
+ const capture = vi.fn()
+ const pageSteps = ['intent', 'app_name', 'app_id', 'app_icon', 'organization', 'setup'] as const
+ const tracker = createOnboardingProgressTracker({
+ ...trackerIdentity,
+ capture,
+ flow: 'pre_org',
+ resumed: false,
+ steps: pageSteps,
+ supaHost: 'https://supabase.capgo.test',
+ })
+
+ tracker.viewStep('app_name')
+ capture.mockClear()
+ tracker.trackDetailsEvent('onboarding_app_name_entered', 'app_name', { field_length: 11 })
+
+ expect(capture).toHaveBeenCalledWith(
+ 'onboarding_app_name_entered',
+ 'https://supabase.capgo.test',
+ expect.objectContaining({
+ field_length: 11,
+ step: 'app_name',
+ step_index: 1,
+ total_steps: 6,
+ }),
+ )
+ })
+
+ it.concurrent('associates app creation outcomes with sanitized choice metadata', () => {
+ const capture = vi.fn()
+ const tracker = createOnboardingProgressTracker({
+ ...trackerIdentity,
+ capture,
+ flow: 'pre_org',
+ resumed: false,
+ steps,
+ supaHost: 'https://supabase.capgo.test',
+ })
+
+ tracker.trackDetailsEvent('onboarding_app_creation_succeeded', 'details', {
+ app_id_source: 'generated',
+ has_icon: true,
+ icon_source: 'store',
+ used_fallback: false,
+ })
+
+ expect(capture).toHaveBeenCalledWith(
+ 'onboarding_app_creation_succeeded',
+ 'https://supabase.capgo.test',
+ expect.objectContaining({
+ app_id_source: 'generated',
+ has_icon: true,
+ icon_source: 'store',
+ used_fallback: false,
+ }),
+ )
+ })
+
it.concurrent('associates organization interactions with the active attempt', () => {
const capture = vi.fn()
const tracker = createOnboardingProgressTracker({
diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts
index 7ab54992e1..7a7901f575 100644
--- a/tests/user-onboarding-progress.unit.test.ts
+++ b/tests/user-onboarding-progress.unit.test.ts
@@ -22,6 +22,7 @@ describe('user onboarding progress', () => {
step: 'organization',
flow: 'pre_org',
intent: 'ota',
+ details_step: 'icon',
app_name: 'Acme',
app_id: 'com.acme.app',
existing_app: false,
@@ -34,6 +35,7 @@ describe('user onboarding progress', () => {
step: 'organization',
flow: 'pre_org',
intent: 'ota',
+ details_step: 'icon',
app_name: 'Acme',
app_id: 'com.acme.app',
existing_app: false,
@@ -66,6 +68,7 @@ describe('user onboarding progress', () => {
step: 'details',
flow: 'pre_org',
intent: 'builder',
+ detailsStep: 'app_id',
appName: ' Hello ',
appId: '',
existingApp: true,
@@ -82,6 +85,7 @@ describe('user onboarding progress', () => {
step: 'details',
flow: 'pre_org',
intent: 'builder',
+ details_step: 'app_id',
app_name: 'Hello',
existing_app: true,
existing_app_setup: 'import',
@@ -108,6 +112,13 @@ describe('user onboarding progress', () => {
intent: 'ota',
updatedAt: '2026-08-15T00:00:00.000Z',
}), 'pre_org')).toBe(true)
+ expect(shouldPromptOnboardingResume(buildUserOnboardingProgress({
+ status: 'in_progress',
+ step: 'details',
+ flow: 'existing_org',
+ detailsStep: 'app_id',
+ updatedAt: '2026-08-15T00:00:00.000Z',
+ }), 'existing_org')).toBe(true)
expect(shouldPromptOnboardingResume(buildUserOnboardingProgress({
status: 'in_progress',
step: 'organization',