Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,18 @@ const Constants = {
'category': 'User',
'event': `User oauth ${type}`,
}),
'ONBOARDING_AI_CONNECT': {
'category': 'Onboarding',
'event': 'Onboarding AI connect used',
},
'ONBOARDING_FLAG_TOGGLED': {
'category': 'Onboarding',
'event': 'Onboarding flag toggled',
},
'ONBOARDING_SNIPPET_COPIED': {
'category': 'Onboarding',
'event': 'Onboarding snippet copied',
},
'REFERRER_CONVERSION': (referrer: string) => ({
'category': 'Referrer',
'event': `${referrer} converted`,
Expand Down
11 changes: 11 additions & 0 deletions frontend/common/utils/getOnboardingVariant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Utils from './utils'
Comment thread
kyle-ssg marked this conversation as resolved.

export type OnboardingVariant = 'control' | 'single_page'

export const getOnboardingVariant = (): OnboardingVariant =>
Utils.getFlagsmithHasFeature('onboarding_quickstart_flow')
? 'single_page'
: 'control'
Comment on lines +5 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we plan to release this one ? Should we directly have 2 variants in the feature and grab the variant name from the flag ?

@talissoncosta talissoncosta Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question @Zaimwa9. I think this concern will be addressed on next issue. I will keep it simple now, ff will be boolean for now until we decided the path forward.


export const isSinglePageOnboarding = (): boolean =>
getOnboardingVariant() === 'single_page'
29 changes: 15 additions & 14 deletions frontend/web/components/pages/onboarding/GettingStartedGate.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import React, { FC } from 'react'
import Utils from 'common/utils/utils'
import React, { FC, useEffect } from 'react'
import {
getOnboardingVariant,
isSinglePageOnboarding,
} from 'common/utils/getOnboardingVariant'
import API from 'project/api'
import GettingStartedPage from 'components/pages/GettingStartedPage'
import OnboardingFlow from './OnboardingFlow'

// Gates /getting-started: renders the new onboarding flow when the
// `onboarding_quickstart_flow` flag is on, otherwise the existing page.
//
// Deliberately a flat file, not a folder + barrel - a one-line route gate with
// no styles or sub-parts, the documented exception to the component-folder
// convention (frontend/CLAUDE.md rule 8).
const GettingStartedGate: FC = () =>
Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') ? (
<OnboardingFlow />
) : (
<GettingStartedPage />
)
const GettingStartedGate: FC = () => {
const variant = getOnboardingVariant()

useEffect(() => {
API.trackTraits({ onboarding_variant: variant })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tagged at the gate (only users who reach onboarding). set for now; immutable setOnce in #8061.

}, [variant])

return isSinglePageOnboarding() ? <OnboardingFlow /> : <GettingStartedPage />
}

export default GettingStartedGate
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useCopyFeedback } from 'components/pages/onboarding/hooks/useCopyFeedba
export type ConnectWithAiPanelProps = {
environmentKey: string
featureName: string
onCopyPrompt?: () => void
}

// "Connect with AI" tab: an agent-agnostic prompt the user pastes into their
Expand All @@ -18,6 +19,7 @@ export type ConnectWithAiPanelProps = {
const ConnectWithAiPanel: FC<ConnectWithAiPanelProps> = ({
environmentKey,
featureName,
onCopyPrompt,
}) => {
const { copied, copy } = useCopyFeedback()

Expand All @@ -39,7 +41,14 @@ Detect my stack, install the SDK, and wire ${featureName} into one place. Then r
<code className='onboarding-connect__prompt-text flex-fill'>
{aiPrompt}
</code>
<Button theme='outline' size='small' onClick={() => copy(aiPrompt)}>
<Button
theme='outline'
size='small'
onClick={() => {
copy(aiPrompt)
onCopyPrompt?.()
}}
>
<span
className='d-inline-flex align-items-center gap-1'
aria-live='polite'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type OnboardingConnectPanelProps = {
featureName: string
onCopyInstall?: () => void
onCopyWire?: () => void
onCopyPrompt?: () => void
}

// Two ways to connect an app to the pre-created flag: paste an agent-agnostic
Expand All @@ -40,6 +41,7 @@ const OnboardingConnectPanel: FC<OnboardingConnectPanelProps> = ({
environmentKey,
featureName,
onCopyInstall,
onCopyPrompt,
onCopyWire,
}) => {
const [tab, setTab] = useState<ConnectTab>('manual')
Expand All @@ -61,6 +63,7 @@ const OnboardingConnectPanel: FC<OnboardingConnectPanelProps> = ({
<ConnectWithAiPanel
environmentKey={environmentKey}
featureName={featureName}
onCopyPrompt={onCopyPrompt}
/>
</OnboardingTabPanel>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import { useOnboardingFlag } from 'components/pages/onboarding/hooks/useOnboardi
import { useOnboardingConnection } from 'components/pages/onboarding/hooks/useOnboardingConnection'
import { useUpdateOrganisationMutation } from 'common/services/useOrganisation'
import { useUpdateProjectMutation } from 'common/services/useProject'
import API from 'project/api'
import Constants from 'common/constants'
import './OnboardingFlow.scss'

type OnboardingSnippet = 'install' | 'wire'

// The single-page onboarding flow, rendered at /getting-started when
// onboarding_quickstart_flow is on (see GettingStartedGate).
const OnboardingFlow: FC = () => {
Expand Down Expand Up @@ -139,6 +143,38 @@ const OnboardingFlow: FC = () => {
history.push(`${base}/features?feature=${flagId}&tab=${tab}`)
}

const diagnosticIds = {
environment_id: environment?.id,
organisation_id: organisationId,
project_id: projectId,
}
const trackSnippetCopied = (snippet: OnboardingSnippet) =>
API.trackEvent({
...Constants.events.ONBOARDING_SNIPPET_COPIED,
extra: { ...diagnosticIds, snippet },
})
const handleCopyInstall = () => {
trackSnippetCopied('install')
setInstallCopied(true)
}
const handleCopyWire = () => {
trackSnippetCopied('wire')
setSnippetCopied(true)
}
const handleCopyPrompt = () =>
API.trackEvent({
...Constants.events.ONBOARDING_AI_CONNECT,
extra: diagnosticIds,
})
const handleToggleFlag = async (enabled: boolean) => {
if (await toggleFlag(enabled)) {
API.trackEvent({
...Constants.events.ONBOARDING_FLAG_TOGGLED,
extra: { ...diagnosticIds, enabled },
})
}
}

if (status === 'creating') {
return (
<div className='onboarding-flow mx-auto text-center'>
Expand Down Expand Up @@ -177,8 +213,9 @@ const OnboardingFlow: FC = () => {
<OnboardingConnectPanel
environmentKey={environmentKey}
featureName={featureName}
onCopyInstall={() => setInstallCopied(true)}
onCopyWire={() => setSnippetCopied(true)}
onCopyInstall={handleCopyInstall}
onCopyWire={handleCopyWire}
onCopyPrompt={handleCopyPrompt}
/>
<OnboardingTerminal
featureName={featureName}
Expand All @@ -196,7 +233,7 @@ const OnboardingFlow: FC = () => {
tags: flagTags,
},
]}
onToggle={(_flag, next) => toggleFlag(next)}
onToggle={(_flag, next) => handleToggleFlag(next)}
togglingFlag={isToggling ? featureName : null}
togglesReady={flagStateReady}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@ import {
} from 'common/types/responses'
import { SmartDefaults } from './useSmartDefaults'
import { createOrganisationViaAccountStore } from './createOrganisationViaAccountStore'
import API from 'project/api'
import Constants from 'common/constants'

type Store = ReturnType<typeof getStore>

const FLAG_NAME = 'show_demo_button'
const DEFAULT_ORG_NAME = 'My organisation'
const DEFAULT_PROJECT_NAME = 'My first project'
// Written on create and matched by name on reuse, so the two must stay in step.
const DEV_ENVIRONMENT_NAME = 'Development'
const PROD_ENVIRONMENT_NAME = 'Production'
// Attached to the demo flag so the flags table shows an "Onboarding" badge.
// Green from the product tag palette (Constants.tagColors), not an arbitrary hex.
const ONBOARDING_TAG = {
color: '#3cb371',
description: 'Created during onboarding',
Expand All @@ -50,13 +49,12 @@ export type OnboardingBootstrap = {
featureName: string
}

// Reuse the loaded org, or create one only when the user has none (the plan
// org cap rejects extra creates with a 403). Create goes through the legacy
// AccountStore so the shell's current-org selection is populated.
async function ensureOrganisation(
store: Store,
{ defaults, existingOrg }: BootstrapInput,
): Promise<ExistingOrg> {
// Create only when the user has none: the plan org cap rejects extra creates
// with a 403. Goes through AccountStore so the shell adopts the new org.
if (existingOrg) {
return existingOrg
}
Expand All @@ -71,8 +69,6 @@ async function ensureOrganisation(
return { id, name }
}

// Reuse the first project, else create one with Development + Production
// environments. Avoids stacking up duplicate projects on revisits.
async function ensureProject(
store: Store,
organisationId: number,
Expand All @@ -93,6 +89,7 @@ async function ensureProject(
}),
)
.unwrap()
API.trackEvent(Constants.events.CREATE_FIRST_PROJECT)
await store
.dispatch(
environmentService.endpoints.createEnvironment.initiate({
Expand All @@ -112,8 +109,6 @@ async function ensureProject(
return project
}

// Surface an environment key: reuse one (preferring Development), or create it
// only if the reused project somehow has none.
async function ensureEnvironments(
store: Store,
project: ProjectSummary,
Expand All @@ -138,7 +133,6 @@ async function ensureEnvironments(
.unwrap()
}

// Find the project's Onboarding tag, if it's been created yet.
async function findOnboardingTag(
store: Store,
projectId: number,
Expand All @@ -149,8 +143,6 @@ async function findOnboardingTag(
return tags?.find((t) => t.label === ONBOARDING_TAG.label)
}

// Reuse the onboarding flag, matched by its Onboarding tag (or its name) so we
// never grab one of the user's other flags; else create the demo flag.
async function ensureFlag(
store: Store,
project: ProjectSummary,
Expand All @@ -170,7 +162,8 @@ async function ensureFlag(
if (existing) {
return existing
}
return store
const isFirstFeature = !flags?.results?.length
const created = await store
.dispatch(
projectFlagService.endpoints.createProjectFlag.initiate({
body: {
Expand All @@ -182,11 +175,12 @@ async function ensureFlag(
}),
)
.unwrap()
if (isFirstFeature) {
API.trackEvent(Constants.events.CREATE_FIRST_FEATURE)
}
return created
}

// Attach the "Onboarding" tag to the demo flag (find-or-create), so the flags
// table shows the badge from the design. Best-effort: tagging is cosmetic and
// must never block the bootstrap.
async function ensureOnboardingTag(
store: Store,
project: ProjectSummary,
Expand Down Expand Up @@ -215,14 +209,10 @@ async function ensureOnboardingTag(
.unwrap()
}
} catch {
// Cosmetic only - never block onboarding on tagging.
// Cosmetic: tagging must never block onboarding.
}
}

// Idempotently bring up everything the single-page flow needs: org, project,
// Development + Production environments, and a first flag, reusing whatever
// already exists. Split into ensure* steps so each concern is testable and the
// reuse-vs-create decisions are obvious.
export async function bootstrapOnboarding(
store: Store,
input: BootstrapInput,
Expand All @@ -234,7 +224,6 @@ export async function bootstrapOnboarding(
if (flag) {
await ensureOnboardingTag(store, project, flag)
}
// Refresh the legacy org store so the shell sees the project.
AppActions.refreshOrganisation()
return {
environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,21 @@ export const useOnboardingFlag = (
const { displayEnabled, isLoading, revertToggle, startToggle } =
useFeatureRowState(state?.enabled)

const toggle = async (enabled: boolean) => {
const toggle = async (enabled: boolean): Promise<boolean> => {
if (!environment || !state || !startToggle(enabled)) {
return
return false
}
try {
await updateFeatureState({
body: { enabled },
environmentFlagId: state.id,
environmentId: environment.api_key,
}).unwrap()
return true
} catch {
revertToggle()
toast('Couldn’t update your flag. Please try again.', 'danger')
return false
}
}

Expand Down
Loading