From 2f5c6ee6af65febfde631ee4a799c344a14ec956 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Thu, 28 May 2026 17:40:55 -0300 Subject: [PATCH 01/21] feat(onboarding-quickstart): role-based onboarding with PM branching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder/identifier rename from "onboarding-aha" to "onboarding-quickstart" matches the design intent (fast path to first eval) rather than the internal product term. Includes the SCSS class prefix, the feature flag name (`onboarding_quickstart_flow`), the page component name, and the track-event namespace. New shape adds a role-selection step at the start (Engineer / PM / Other) and branches the flow: - Engineer: SDK install snippet + first-eval polling (existing) - PM: integrations grid (visual capability check) + invite-an-engineer - Other: skips the evaluation step entirely, drops to features page Other changes folded in: - CodeSnippet rebuilt with correct package names (`@flagsmith/flagsmith`), minimal per-language snippets, and interpolation of the user's chosen feature name (not placeholder identifiers) - Custom toggle replaced with the existing `Switch` component - Skip button consolidated to the page header (was duplicated in each step footer) - "Choose for me" buttons removed from Org + Project steps — the pre-fill via `useSmartDefaults` does the same job - Per-block layout constraints (form steps narrow, evaluation step wide, page itself full-width) — drops the legacy 1280px page cap - text-secondary → text-muted across the flow to avoid the Bootstrap `$secondary` (#fae392) collision that fails contrast on light surfaces - "Aha" terminology dropped from step names — internal key `'evaluation'`, user-facing title "See it works" The activation signal (real first-SDK-eval detection) is still the polling stub from earlier — replacement signal needs to pull from Influx, tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../navigation/navbars/TopNavbar.tsx | 28 +- .../onboarding-quickstart/ConceptDrawer.scss | 86 ++++ .../onboarding-quickstart/ConceptDrawer.tsx | 142 ++++++ .../onboarding-quickstart/OnboardingChip.scss | 64 +++ .../onboarding-quickstart/OnboardingChip.tsx | 41 ++ .../OnboardingChipWithDrawer.tsx | 51 +++ .../GettingStartedSwitch.tsx | 16 + .../OnboardingQuickstartPage.scss | 426 ++++++++++++++++++ .../OnboardingQuickstartPage.tsx | 212 +++++++++ .../components/CodeSnippet.tsx | 169 +++++++ .../components/FeatureEvaluationStep.tsx | 142 ++++++ .../components/FeatureStep.tsx | 115 +++++ .../components/OnboardingStepper.tsx | 89 ++++ .../components/OrgStep.tsx | 42 ++ .../components/ProjectStep.tsx | 50 ++ .../components/RoleStep.tsx | 61 +++ .../components/StatusPanel.tsx | 88 ++++ .../components/StepShell.tsx | 23 + .../components/SuccessActions.tsx | 65 +++ .../onboarding-quickstart/data/presets.ts | 45 ++ .../pages/onboarding-quickstart/data/roles.ts | 12 + .../hooks/useFirstEvaluationPoll.ts | 51 +++ .../hooks/useSmartDefaults.ts | 40 ++ frontend/web/routes.js | 4 +- 24 files changed, 2054 insertions(+), 8 deletions(-) create mode 100644 frontend/web/components/onboarding-quickstart/ConceptDrawer.scss create mode 100644 frontend/web/components/onboarding-quickstart/ConceptDrawer.tsx create mode 100644 frontend/web/components/onboarding-quickstart/OnboardingChip.scss create mode 100644 frontend/web/components/onboarding-quickstart/OnboardingChip.tsx create mode 100644 frontend/web/components/onboarding-quickstart/OnboardingChipWithDrawer.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/GettingStartedSwitch.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.scss create mode 100644 frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/CodeSnippet.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/FeatureEvaluationStep.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/FeatureStep.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/OnboardingStepper.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/OrgStep.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/RoleStep.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/StatusPanel.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/StepShell.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx create mode 100644 frontend/web/components/pages/onboarding-quickstart/data/presets.ts create mode 100644 frontend/web/components/pages/onboarding-quickstart/data/roles.ts create mode 100644 frontend/web/components/pages/onboarding-quickstart/hooks/useFirstEvaluationPoll.ts create mode 100644 frontend/web/components/pages/onboarding-quickstart/hooks/useSmartDefaults.ts diff --git a/frontend/web/components/navigation/navbars/TopNavbar.tsx b/frontend/web/components/navigation/navbars/TopNavbar.tsx index 8eb7f5b2c147..a24b83b39896 100644 --- a/frontend/web/components/navigation/navbars/TopNavbar.tsx +++ b/frontend/web/components/navigation/navbars/TopNavbar.tsx @@ -7,6 +7,7 @@ import Icon from 'components/icons/Icon' import Headway from 'components/Headway' import { Project } from 'common/types/responses' import AccountDropdown from 'components/navigation/AccountDropdown' +import OnboardingChipWithDrawer from 'web/components/onboarding-quickstart/OnboardingChipWithDrawer' type TopNavType = { activeProject: Project | undefined @@ -25,15 +26,30 @@ const TopNavbar: FC = ({ activeProject, projectId }) => {
+ {/* TEMP: forced on for local validation. Restore the flag check + (Utils.getFlagsmithHasFeature('onboarding_quickstart_flow')) before merge. + Original NavLink left below in a comment for easy revert. + + + + + Getting Started + + */} + + {/* TEMP: test-only quick link to the AHA onboarding flow. + Remove before merge along with the FORCE_ON gates. */} - - - - Getting Started + + Test onboarding void + onItemClick: (id: ConceptItemId) => void +} + +const ConceptDrawer: FC = ({ + activeId, + completedIds, + isOpen, + onClose, + onItemClick, +}) => { + useEffect(() => { + if (!isOpen) return + const handler = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [isOpen, onClose]) + + const completedCount = completedIds.length + const totalCount = CONCEPT_ITEMS.length + + return ( + <> + + } + footer={ + <> + + + + } + /> + ) +} + +export default FeatureStep diff --git a/frontend/web/components/pages/onboarding-quickstart/components/OnboardingStepper.tsx b/frontend/web/components/pages/onboarding-quickstart/components/OnboardingStepper.tsx new file mode 100644 index 000000000000..b06fcc3dd254 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/OnboardingStepper.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' + +export type OnboardingStepKey = + | 'role' + | 'org' + | 'project' + | 'feature' + | 'evaluation' + +type OnboardingStepStatus = 'done' | 'active' | 'upcoming' + +export type OnboardingStepDef = { + key: OnboardingStepKey + title: string +} + +type OnboardingStepperProps = { + currentStep: OnboardingStepKey + onStepClick?: (key: OnboardingStepKey) => void + steps: OnboardingStepDef[] +} + +const indexFor = (steps: OnboardingStepDef[], key: OnboardingStepKey): number => + steps.findIndex((step) => step.key === key) + +const statusFor = ( + index: number, + currentIndex: number, +): OnboardingStepStatus => { + if (index < currentIndex) return 'done' + if (index === currentIndex) return 'active' + return 'upcoming' +} + +const OnboardingStepper: FC = ({ + currentStep, + onStepClick, + steps, +}) => { + const currentIndex = indexFor(steps, currentStep) + + return ( +
    + {steps.map((step, index) => { + const status = statusFor(index, currentIndex) + const isClickable = status === 'done' && !!onStepClick + const showConnector = index < steps.length - 1 + return ( +
  1. + + {showConnector && ( +
  2. + ) + })} +
+ ) +} + +export default OnboardingStepper diff --git a/frontend/web/components/pages/onboarding-quickstart/components/OrgStep.tsx b/frontend/web/components/pages/onboarding-quickstart/components/OrgStep.tsx new file mode 100644 index 000000000000..69cac246c2e4 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/OrgStep.tsx @@ -0,0 +1,42 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import InputGroup from 'components/base/forms/InputGroup' +import StepShell from 'web/components/pages/onboarding-quickstart/components/StepShell' + +type OrgStepProps = { + onChange: (value: string) => void + onNext: () => void + value: string +} + +const OrgStep: FC = ({ onChange, onNext, value }) => { + const isValid = !!value.trim() + + return ( + ) => + onChange(e.target.value) + } + isValid={isValid} + /> + } + footer={ + <> + + + + } + /> + ) +} + +export default OrgStep diff --git a/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx b/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx new file mode 100644 index 000000000000..3cfa3ec9b048 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx @@ -0,0 +1,50 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import InputGroup from 'components/base/forms/InputGroup' +import StepShell from 'web/components/pages/onboarding-quickstart/components/StepShell' + +type ProjectStepProps = { + onBack: () => void + onChange: (value: string) => void + onNext: () => void + value: string +} + +const ProjectStep: FC = ({ + onBack, + onChange, + onNext, + value, +}) => { + const isValid = !!value.trim() + + return ( + ) => + onChange(e.target.value) + } + isValid={isValid} + /> + } + footer={ + <> + + + + } + /> + ) +} + +export default ProjectStep diff --git a/frontend/web/components/pages/onboarding-quickstart/components/RoleStep.tsx b/frontend/web/components/pages/onboarding-quickstart/components/RoleStep.tsx new file mode 100644 index 000000000000..79caf7517ac3 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/RoleStep.tsx @@ -0,0 +1,61 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import StepShell from 'web/components/pages/onboarding-quickstart/components/StepShell' +import { + ONBOARDING_ROLES, + OnboardingRoleKey, +} from 'web/components/pages/onboarding-quickstart/data/roles' + +type RoleStepProps = { + onChange: (role: OnboardingRoleKey) => void + onNext: () => void + value: OnboardingRoleKey | null +} + +const RoleStep: FC = ({ onChange, onNext, value }) => { + const isValid = !!value + + return ( + + {ONBOARDING_ROLES.map((role) => { + const isSelected = value === role.key + return ( + + ) + })} + + } + footer={ + <> + + + + } + /> + ) +} + +export default RoleStep diff --git a/frontend/web/components/pages/onboarding-quickstart/components/StatusPanel.tsx b/frontend/web/components/pages/onboarding-quickstart/components/StatusPanel.tsx new file mode 100644 index 000000000000..7ab0bd0b02b5 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/StatusPanel.tsx @@ -0,0 +1,88 @@ +import React, { FC } from 'react' +import Icon from 'components/icons/Icon' +import Switch from 'components/Switch' + +type StatusPanelProps = { + featureName: string + isReceived: boolean + onToggle?: () => void + toggleValue: boolean +} + +const StatusPanel: FC = ({ + featureName, + isReceived, + onToggle, + toggleValue, +}) => { + const statusClass = isReceived + ? 'onboarding-quickstart__status--received bg-surface-success border-success' + : 'onboarding-quickstart__status--waiting bg-surface-warning border-warning' + + // Eval count is stubbed for the POC — backend signal not yet wired. + const statusLabel = isReceived + ? 'First request received · 3 evaluations in last minute' + : 'Waiting for first request from your app…' + + // `text-warning` / `text-success` semantic tokens fail contrast against + // their own tinted surfaces, so this panel uses the locally-defined + // *-strong text rules in OnboardingQuickstartPage.scss. + const statusTextClass = isReceived + ? 'onboarding-quickstart__status-text--received' + : 'onboarding-quickstart__status-text--waiting' + + return ( +
+
+
+ +
+
+ + Your app + + + + Flagsmith + +
+ +
+ {featureName} + isReceived && onToggle?.()} + aria-label={`Toggle ${featureName}`} + /> +
+
+ + {!isReceived && ( +

+ Paste the snippet below and run your app — we'll detect the first + request automatically. +

+ )} + {isReceived && ( +

+ You're live. Flip the toggle above, then configure targeting, add + segments, or roll out gradually whenever you're ready. +

+ )} +
+ ) +} + +export default StatusPanel diff --git a/frontend/web/components/pages/onboarding-quickstart/components/StepShell.tsx b/frontend/web/components/pages/onboarding-quickstart/components/StepShell.tsx new file mode 100644 index 000000000000..9f46615d3edc --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/StepShell.tsx @@ -0,0 +1,23 @@ +import React, { FC, ReactNode } from 'react' + +type StepShellProps = { + body: ReactNode + footer: ReactNode + subtitle: ReactNode + title: string +} + +const StepShell: FC = ({ body, footer, subtitle, title }) => ( +
+

{title}

+

{subtitle}

+ +
{body}
+ +
+ {footer} +
+
+) + +export default StepShell diff --git a/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx b/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx new file mode 100644 index 000000000000..658d4e1197f8 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx @@ -0,0 +1,65 @@ +import React, { FC } from 'react' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import { OnboardingRoleKey } from 'web/components/pages/onboarding-quickstart/data/roles' + +type SuccessActionsProps = { + onExplore: () => void + onInvite: () => void + role: OnboardingRoleKey +} + +const COPY_BY_ROLE: Record< + OnboardingRoleKey, + { heading: string; subtitle: string; inviteLabel: string } +> = { + engineer: { + heading: "Nice — you've shipped your first eval.", + inviteLabel: 'Invite a teammate', + subtitle: + 'Get the rest of your team in so they can target users and roll out gradually.', + }, + // 'other' never reaches this component — they skip AHA — but typing it + // here keeps the Record exhaustive in case the routing changes. + other: { + heading: "You're in.", + inviteLabel: 'Invite a teammate', + subtitle: 'Explore the dashboard or invite teammates when you’re ready.', + }, + + pm: { + heading: 'Your dashboard is ready.', + inviteLabel: 'Invite an engineer', + subtitle: + 'Invite an engineer to wire Flagsmith into your codebase and ship the first flag.', + }, +} + +const SuccessActions: FC = ({ + onExplore, + onInvite, + role, +}) => { + const copy = COPY_BY_ROLE[role] + return ( +
+
+

{copy.heading}

+

{copy.subtitle}

+
+
+ + +
+
+ ) +} + +export default SuccessActions diff --git a/frontend/web/components/pages/onboarding-quickstart/data/presets.ts b/frontend/web/components/pages/onboarding-quickstart/data/presets.ts new file mode 100644 index 000000000000..86dd00d58823 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/data/presets.ts @@ -0,0 +1,45 @@ +export type FeaturePreset = { + key: string + label: string +} + +export const FEATURE_PRESETS: FeaturePreset[] = [ + { + key: 'show_demo_button', + label: 'show_demo_button — toggle a button in your UI', + }, + { + key: 'send_onboarding_email', + label: 'send_onboarding_email — gate an email send', + }, +] + +export const CONCEPT_ITEMS = [ + { + description: 'Auto-ticked when you ran the snippet.', + id: 'first-flag', + title: 'Create your first flag', + }, + { + description: 'Get your team set up in Flagsmith.', + id: 'invite-teammate', + title: 'Invite a teammate', + }, + { + description: 'Use segments + identities to target a subset of users.', + id: 'target-user', + title: 'Target a specific user', + }, + { + description: 'Percentage rollouts to release gradually.', + id: 'rollout', + title: 'Roll out gradually', + }, + { + description: 'Use multiple environments to promote between them.', + id: 'promote', + title: 'Promote to production', + }, +] as const + +export type ConceptItemId = (typeof CONCEPT_ITEMS)[number]['id'] diff --git a/frontend/web/components/pages/onboarding-quickstart/data/roles.ts b/frontend/web/components/pages/onboarding-quickstart/data/roles.ts new file mode 100644 index 000000000000..dd5e97e08ec9 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/data/roles.ts @@ -0,0 +1,12 @@ +export type OnboardingRoleKey = 'engineer' | 'pm' | 'other' + +export type OnboardingRole = { + key: OnboardingRoleKey + title: string +} + +export const ONBOARDING_ROLES: OnboardingRole[] = [ + { key: 'engineer', title: 'Engineer' }, + { key: 'pm', title: 'Product manager' }, + { key: 'other', title: 'Something else' }, +] diff --git a/frontend/web/components/pages/onboarding-quickstart/hooks/useFirstEvaluationPoll.ts b/frontend/web/components/pages/onboarding-quickstart/hooks/useFirstEvaluationPoll.ts new file mode 100644 index 000000000000..c1a5e3a80ab2 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/hooks/useFirstEvaluationPoll.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from 'react' + +type PollState = 'idle' | 'waiting' | 'received' + +type UseFirstEvaluationPollOptions = { + enabled: boolean + simulateAfterMs?: number +} + +/** + * Placeholder for the real "first SDK request received" signal. + * Today there is no dedicated backend endpoint for this; the hook + * simulates the receive after `simulateAfterMs` so the UI is demoable. + * Swap the simulation for a polled stat endpoint when one exists. + */ +export const useFirstEvaluationPoll = ({ + enabled, + simulateAfterMs = 8000, +}: UseFirstEvaluationPollOptions) => { + const [state, setState] = useState('idle') + const timeoutRef = useRef | null>(null) + + useEffect(() => { + if (!enabled) { + setState('idle') + return + } + setState('waiting') + timeoutRef.current = setTimeout(() => { + setState('received') + }, simulateAfterMs) + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, [enabled, simulateAfterMs]) + + const markReceived = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + setState('received') + } + + return { markReceived, state } +} + +export type UseFirstEvaluationPollReturn = ReturnType< + typeof useFirstEvaluationPoll +> diff --git a/frontend/web/components/pages/onboarding-quickstart/hooks/useSmartDefaults.ts b/frontend/web/components/pages/onboarding-quickstart/hooks/useSmartDefaults.ts new file mode 100644 index 000000000000..74b0c8086863 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/hooks/useSmartDefaults.ts @@ -0,0 +1,40 @@ +import { useMemo } from 'react' +import isFreeEmailDomain from 'common/utils/isFreeEmailDomain' + +export type SmartDefaults = { + orgName: string + projectName: string + featureName: string +} + +const titleCase = (value: string): string => + value + .split(/[-._\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + +const orgNameFromEmail = (email: string, firstName: string): string => { + if (!email || !email.includes('@')) { + return firstName ? `${firstName}'s Org` : '' + } + const domain = email.split('@')[1] ?? '' + if (!domain || isFreeEmailDomain(`@${domain}`)) { + return firstName ? `${firstName}'s Org` : '' + } + const root = domain.split('.')[0] ?? '' + return root ? titleCase(root) : '' +} + +export const useSmartDefaults = ( + email: string, + firstName: string, +): SmartDefaults => + useMemo( + () => ({ + featureName: 'show_demo_button', + orgName: orgNameFromEmail(email, firstName), + projectName: 'My first project', + }), + [email, firstName], + ) diff --git a/frontend/web/routes.js b/frontend/web/routes.js index cc9802e07536..ccb318dd179c 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -40,7 +40,7 @@ import FeatureHistoryDetailPage from './components/pages/FeatureHistoryDetailPag import OrganisationIntegrationsPage from './components/pages/OrganisationIntegrationsPage' import ProjectChangeRequestsPage from './components/pages/ProjectChangeRequestsPage' import ProjectChangeRequestPage from './components/pages/ProjectChangeRequestDetailPage' -import GettingStartedPage from './components/pages/GettingStartedPage' +import GettingStartedSwitch from './components/pages/onboarding-quickstart/GettingStartedSwitch' import ReleasePipelinesPage from './components/pages/ReleasePipelinesPage' import CreateReleasePipelinePage from './components/pages/CreateReleasePipelinePage' @@ -268,7 +268,7 @@ export default ( Date: Thu, 28 May 2026 18:01:24 -0300 Subject: [PATCH 02/21] feat(onboarding-quickstart): keyboard nav + PM integrations preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyboard accessibility: - Enter on org / project / custom-feature inputs advances the step - Step transition focuses the first interactive element of the new step - RoleStep and FeatureStep now follow WAI-ARIA radiogroup pattern with arrow-key navigation (ArrowUp/Down/Left/Right + Home/End), roving tabindex, and `role='radio'` / `aria-checked` semantics - First arrow press with no selection picks the focused option instead of skipping past it - Enter on a focused option (with a valid selection) advances — saves a Tab + Enter to reach the Next/Finish button - Visible `:focus-visible` outline restored within `.onboarding-quickstart`, scoped to override the project-wide `.btn:focus-visible { box-shadow: none }` rule that hides focus for sighted keyboard users PM path content: - "See it works" stepper label is role-aware: engineer keeps it, PM gets "Connect your tools" - PM evaluation step now shows a read-only integrations grid (visual capability check) using the same data merge as `IntegrationSelect` (Flagsmith remote-config `integration_data` + `Constants.integrationSummaries`, deduped by title). Top 12 entries rendered as cards - Reuse-the-whole `IntegrationSelect` was considered but discarded — its select-tools interaction has no downstream effect today, which would set wrong expectations at the AHA moment - PM CTA copy: "Invite a teammate" (was "Invite an engineer") New primitive: - `BareButton` — a ` - From 664f39f5efe178c8da07a180ed2bb94d45b18604 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 09:51:18 -0300 Subject: [PATCH 05/21] feat(onboarding-quickstart): add RTK mutations for org/project/environment creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds createOrganisation, createProject and createEnvironment RTK Query mutations — previously these only existed in the legacy Flux stores (account-store / organisation-store). These are the foundation for wiring the onboarding create chain to real APIs without leaning on the stores the project is migrating off. Also documents the backend integration plan for the flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/common/services/useEnvironment.ts | 12 +++ frontend/common/services/useOrganisation.ts | 12 +++ frontend/common/services/useProject.ts | 9 +++ frontend/common/types/requests.ts | 3 + .../BACKEND_INTEGRATION_PLAN.md | 77 +++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md diff --git a/frontend/common/services/useEnvironment.ts b/frontend/common/services/useEnvironment.ts index 4a0c76e67f63..40d71733220a 100644 --- a/frontend/common/services/useEnvironment.ts +++ b/frontend/common/services/useEnvironment.ts @@ -6,6 +6,17 @@ export const environmentService = service .enhanceEndpoints({ addTagTypes: ['Environment'] }) .injectEndpoints({ endpoints: (builder) => ({ + createEnvironment: builder.mutation< + Res['environment'], + Req['createEnvironment'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'Environment' }], + query: (body: Req['createEnvironment']) => ({ + body, + method: 'POST', + url: `environments/`, + }), + }), getEnvironment: builder.query({ providesTags: (res) => [{ id: res?.id, type: 'Environment' }], query: (query: Req['getEnvironment']) => ({ @@ -84,6 +95,7 @@ export async function updateEnvironment( // END OF FUNCTION_EXPORTS export const { + useCreateEnvironmentMutation, useGetEnvironmentMetricsQuery, useGetEnvironmentQuery, useGetEnvironmentsQuery, diff --git a/frontend/common/services/useOrganisation.ts b/frontend/common/services/useOrganisation.ts index 82e0f45f79f1..dfb1801108bc 100644 --- a/frontend/common/services/useOrganisation.ts +++ b/frontend/common/services/useOrganisation.ts @@ -6,6 +6,17 @@ export const organisationService = service .enhanceEndpoints({ addTagTypes: ['Organisation'] }) .injectEndpoints({ endpoints: (builder) => ({ + createOrganisation: builder.mutation< + Res['organisation'], + Req['createOrganisation'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'Organisation' }], + query: (body: Req['createOrganisation']) => ({ + body, + method: 'POST', + url: `organisations/`, + }), + }), deleteOrganisation: builder.mutation({ invalidatesTags: [{ id: 'LIST', type: 'Organisation' }], query: ({ id }: Req['deleteOrganisation']) => ({ @@ -85,6 +96,7 @@ export async function getOrganisations( // END OF FUNCTION_EXPORTS export const { + useCreateOrganisationMutation, useDeleteOrganisationMutation, useGetOrganisationQuery, useGetOrganisationsQuery, diff --git a/frontend/common/services/useProject.ts b/frontend/common/services/useProject.ts index 73e4a65c0817..988d7607d6ed 100644 --- a/frontend/common/services/useProject.ts +++ b/frontend/common/services/useProject.ts @@ -7,6 +7,14 @@ export const projectService = service .enhanceEndpoints({ addTagTypes: ['Project'] }) .injectEndpoints({ endpoints: (builder) => ({ + createProject: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'Project' }], + query: (body: Req['createProject']) => ({ + body, + method: 'POST', + url: `projects/`, + }), + }), deleteProject: builder.mutation({ invalidatesTags: [{ id: 'LIST', type: 'Project' }], query: ({ id }: Req['deleteProject']) => ({ @@ -99,6 +107,7 @@ export async function getProject( // END OF FUNCTION_EXPORTS export const { + useCreateProjectMutation, useDeleteProjectMutation, useGetProjectPermissionsQuery, useGetProjectQuery, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 0677227f4702..b7b8c0f92fd5 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -172,6 +172,7 @@ export type Req = { }> getOrganisations: {} getOrganisation: { id: number } + createOrganisation: { name: string } updateOrganisation: { id: number; body: UpdateOrganisationBody } deleteOrganisation: { id: number } uploadOrganisationLicence: { @@ -635,6 +636,7 @@ export type Req = { id: string } getProject: { id: number } + createProject: { name: string; organisation: number } updateProject: { id: number; body: UpdateProjectBody } deleteProject: { id: number } migrateProject: { id: number } @@ -682,6 +684,7 @@ export type Req = { feature_id: number group_ids: number[] } + createEnvironment: { name: string; project: number } updateEnvironment: { id: number; body: Environment } createCloneIdentityFeatureStates: { environment_id: string diff --git a/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md new file mode 100644 index 000000000000..dd9a4824fb04 --- /dev/null +++ b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md @@ -0,0 +1,77 @@ +# Onboarding quickstart — backend integration plan + +> Wiring the flow's create chain to real APIs. Step 5 (first-eval +> signal) stays mocked via `useFirstEvaluationPoll` — out of scope here. +> Companion to `PLAN.md` and `FIRST_EVAL_BACKEND_PLAN.md`. + +## Decisions (locked) + +- **New RTK Query mutations**, not the legacy Flux actions. Aligns with + the migrate-off-`AccountStore`/`OrganisationStore` direction. +- **Two default environments** (Development + Production) per project, + matching what normal project creation produces. Onboarding lands on + Development. + +## The create chain (`handleFinish`) + +``` +createOrganisation({ name }) → org.id (skip if org exists) + → createProject({ name, organisation: org.id }) → project.id + → createEnvironment({ name: 'Development', project }) → env.api_key + → createEnvironment({ name: 'Production', project }) + → createProjectFlag({ project_id, body }) (already wired) + → navigate /project/{project.id}/environment/{dev.api_key}/features +``` + +Each step `.unwrap()`'d so a rejection stops the chain and surfaces an error. + +## Phases + +### Phase 1 — RTK mutations (low risk) +- `requests.ts`: add `createOrganisation`, `createProject`, + `createEnvironment` request types. +- `useOrganisation.ts`: `createOrganisation` mutation, invalidates + `Organisation/LIST`, returns `Res['organisation']`. +- `useProject.ts`: `createProject` mutation, invalidates `Project/LIST`, + returns `Res['project']`. +- `useEnvironment.ts`: `createEnvironment` mutation, invalidates + `Environment/LIST`, returns `Res['environment']`. +- Export the three `useCreate…Mutation` hooks. + +### Phase 2 — Chain in `handleFinish` +- Replace the `DEMO_ENVIRONMENT_KEY` stub with the awaited chain. +- Fix the features URL: route needs **numeric `project.id`** and the + Development **`api_key`** — today it wrongly uses `projectName` as the + path slug (Matt's Step 5 "Explore the dashboard" bug traces here). +- Reuse the create-feature modal's body shape for `createProjectFlag` + (name, type, default values). + +### Phase 3 — Flux coherence (the real risk) +The org switcher, project list, and downstream context read from the +**Flux** `AccountStore`/`OrganisationStore`. Entities created purely via +RTK are invisible to them until refresh. After the chain succeeds, call +`AppActions.refreshOrganisation()` (what Flux `createProject` already +does) so the new org/project appear app-wide. Verify the switcher and +project list reflect the new entities without a hard reload. + +### Phase 4 — Branching + guards +- **Org may already exist**: post-signup `register()` auto-creates an org + when `organisation_name` was given. If an org exists, skip the org + step / reuse it rather than creating a duplicate. +- **Idempotency**: going Back and re-submitting must not create a second + org/project. Store created ids in state; on re-submit, reuse them. +- **Errors**: per-step failure handling (duplicate name, permissions, + self-hosted org-creation restrictions). None today. + +### Phase 5 — Flag + cleanup +- Revert `GettingStartedSwitch` `FORCE_ON = true` to the real + `Utils.getFlagsmithHasFeature('onboarding_quickstart_flow')` gate. + +## Out of scope +- First-eval signal (stays mocked — see `FIRST_EVAL_BACKEND_PLAN.md`). +- PM "Connect your tools" integrations destination (product decision). +- Plan-based "Invite a teammate" gating (needs subscription context). + +## Estimate +~3 dev-days: Phase 1–2 ≈ 1 day; Phases 3–4 (coherence, org-exists, +guards/errors) ≈ 2 days. From 0db3388292028fbc67afa2821c2105bd7e73a2b4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 10:01:54 -0300 Subject: [PATCH 06/21] feat(onboarding-quickstart): wire the create chain to real APIs Replaces the DEMO_ENVIRONMENT_KEY stub with the real create chain: reuse-or-create organisation -> create project -> create Development + Production environments -> create the first feature flag, then land on the project's features page. - Organisation is hybrid: reuse the already-selected org (common after signup) and skip the org step; create + select one only when none exists. Org selection state is Flux/Redux-owned, so a pure-RTK create would leave the shell unaware of the new org. - Fixes the features URL to use the numeric project id and the environment api_key (was wrongly using the project name as a slug). - Step navigation is now array-driven so it stays correct as steps are added/removed (org skip, role branching). - Per-step error handling surfaces failures via ErrorMessage. - Refreshes the legacy organisation store so the shell picks up the new project without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BACKEND_INTEGRATION_PLAN.md | 66 ++++--- .../OnboardingQuickstartPage.tsx | 187 +++++++++++++----- 2 files changed, 178 insertions(+), 75 deletions(-) diff --git a/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md index dd9a4824fb04..e91a789acba6 100644 --- a/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md +++ b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md @@ -6,11 +6,21 @@ ## Decisions (locked) -- **New RTK Query mutations**, not the legacy Flux actions. Aligns with - the migrate-off-`AccountStore`/`OrganisationStore` direction. +- **New RTK Query mutations** for project / environment / feature, not the + legacy Flux actions. Aligns with the migrate-off-`AccountStore`/ + `OrganisationStore` direction. +- **Organisation is hybrid**: reuse the already-selected org (the common + case — self-serve signup creates + selects one at registration) and skip + the org step; only create one when none exists. Org *selection* state is + Flux/Redux-owned (`selectedOrganisation` slice), so a pure-RTK create + would leave the shell unaware of the new org. The no-org create path uses + the awaitable RTK `createOrganisation` mutation + `setSelectedOrganisationId` + (cleaner than the fire-and-forget `AppActions.createOrganisation`, which + returns no id). - **Two default environments** (Development + Production) per project, matching what normal project creation produces. Onboarding lands on - Development. + Development. Sample identities (created by the Flux flow) are omitted — + not needed to get a flag working. ## The create chain (`handleFinish`) @@ -27,7 +37,7 @@ Each step `.unwrap()`'d so a rejection stops the chain and surfaces an error. ## Phases -### Phase 1 — RTK mutations (low risk) +### Phase 1 — RTK mutations (low risk) — DONE - `requests.ts`: add `createOrganisation`, `createProject`, `createEnvironment` request types. - `useOrganisation.ts`: `createOrganisation` mutation, invalidates @@ -38,30 +48,34 @@ Each step `.unwrap()`'d so a rejection stops the chain and surfaces an error. `Environment/LIST`, returns `Res['environment']`. - Export the three `useCreate…Mutation` hooks. -### Phase 2 — Chain in `handleFinish` -- Replace the `DEMO_ENVIRONMENT_KEY` stub with the awaited chain. -- Fix the features URL: route needs **numeric `project.id`** and the - Development **`api_key`** — today it wrongly uses `projectName` as the - path slug (Matt's Step 5 "Explore the dashboard" bug traces here). -- Reuse the create-feature modal's body shape for `createProjectFlag` - (name, type, default values). +### Phase 2 — Chain in `handleFinish` — DONE +- Replaced the `DEMO_ENVIRONMENT_KEY` stub with the awaited chain + (org-reuse-or-create → project → Dev + Prod envs → feature flag). +- Fixed the features URL: now uses **numeric `project.id`** and the + Development **`api_key`** (Matt's Step 5 "Explore the dashboard" bug). +- Minimal `createProjectFlag` body (`name`, `project`, `type: STANDARD`), + asserted to the request type. +- Per-step error handling via try/catch → `` on the feature + step; failures stop the chain. +- Absorbed the org-exists branch (was Phase 4): org step is dropped from + the timeline when an org is already selected; navigation is array-driven + so it stays correct. -### Phase 3 — Flux coherence (the real risk) -The org switcher, project list, and downstream context read from the -**Flux** `AccountStore`/`OrganisationStore`. Entities created purely via -RTK are invisible to them until refresh. After the chain succeeds, call -`AppActions.refreshOrganisation()` (what Flux `createProject` already -does) so the new org/project appear app-wide. Verify the switcher and -project list reflect the new entities without a hard reload. +### Phase 3 — Flux coherence (partially done; needs runtime verification) +After the chain, `AppActions.refreshOrganisation()` is called so the +legacy `OrganisationStore` project list/switcher pick up the new project. +**Still to verify by running the app**: that the switcher + project list +reflect the new entities without a hard reload, and that the no-org create +path (RTK `createOrganisation` + `setSelectedOrganisationId`) leaves any +legacy `AccountStore`-reading surfaces coherent. -### Phase 4 — Branching + guards -- **Org may already exist**: post-signup `register()` auto-creates an org - when `organisation_name` was given. If an org exists, skip the org - step / reuse it rather than creating a duplicate. -- **Idempotency**: going Back and re-submitting must not create a second - org/project. Store created ids in state; on re-submit, reuse them. -- **Errors**: per-step failure handling (duplicate name, permissions, - self-hosted org-creation restrictions). None today. +### Phase 4 — Remaining guards +- **Idempotency**: going Back after a *partial* failure and re-submitting + can still create a second project/env. Store created ids in state and + reuse on retry. (Double-submit during a run is already blocked by + `isSubmitting`.) +- **Self-hosted / permissions**: org creation may be restricted; surface a + clear message rather than a raw error. ### Phase 5 — Flag + cleanup - Revert `GettingStartedSwitch` `FORCE_ON = true` to the real diff --git a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx index f6bd2a375c84..5757476d89b4 100644 --- a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx @@ -1,7 +1,18 @@ import React, { FC, useEffect, useMemo, useRef, useState } from 'react' import { useHistory } from 'react-router-dom' +import { useDispatch } from 'react-redux' import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' import { useGetProfileQuery } from 'common/services/useProfile' +import { useCreateOrganisationMutation } from 'common/services/useOrganisation' +import { useCreateProjectMutation } from 'common/services/useProject' +import { useCreateEnvironmentMutation } from 'common/services/useEnvironment' +import { useCreateProjectFlagMutation } from 'common/services/useProjectFlag' +import useSelectedOrganisation from 'common/hooks/useSelectedOrganisation' +import { setSelectedOrganisationId } from 'common/selectedOrganisationSlice' +import AppActions from 'common/dispatcher/app-actions' +import { Req } from 'common/types/requests' +import { ProjectFlag } from 'common/types/responses' import OnboardingStepper, { OnboardingStepDef, OnboardingStepKey, @@ -27,20 +38,29 @@ const trackEvent = (event: string, attributes: Record = {}) => // eslint-disable-next-line no-console console.info(`[onboarding.quickstart] ${event}`, attributes) -// POC stub — replaced by the env key returned from the create-environment -// API call once the backend chain is wired up. -const DEMO_ENVIRONMENT_KEY = 'demo-environment-key-replace-me' - const OnboardingQuickstartPage: FC = () => { const history = useHistory() + const dispatch = useDispatch() const { data: profile } = useGetProfileQuery({}) const defaults = useSmartDefaults( profile?.email ?? '', profile?.first_name ?? '', ) + // Self-serve signup creates and selects an organisation at registration, so + // most users reaching onboarding already have one — reuse it and skip the + // org step. Only when none exists do we create one (rare). + const selectedOrganisation = useSelectedOrganisation() + const hasExistingOrg = !!selectedOrganisation + + const [createOrganisation] = useCreateOrganisationMutation() + const [createProject] = useCreateProjectMutation() + const [createEnvironment] = useCreateEnvironmentMutation() + const [createProjectFlag] = useCreateProjectFlagMutation() + const [step, setStep] = useState('role') const [isSubmitting, setIsSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) const [selectedRole, setSelectedRole] = useState( null, ) @@ -50,6 +70,9 @@ const OnboardingQuickstartPage: FC = () => { FEATURE_PRESETS[0]?.key ?? '', ) const [customFeature, setCustomFeature] = useState('') + // Set from the created project + its Development environment, used to build + // the post-onboarding features URL and the evaluation step's snippet. + const [projectId, setProjectId] = useState(null) const [environmentKey, setEnvironmentKey] = useState('') // Org name is pre-filled from the email domain — it's meaningful data the @@ -84,51 +107,114 @@ const OnboardingQuickstartPage: FC = () => { const evaluationStepTitle = selectedRole === 'pm' ? 'Connect your tools' : 'See it works' - // Other role skips the evaluation step — drop it from the timeline so - // the stepper accurately reflects the flow they'll actually walk. + // The timeline adapts to the user: the org step is dropped when they + // already have one, and the evaluation step is dropped for the 'other' + // role (Other = "orient, don't commit"), so the stepper reflects the flow + // they'll actually walk. const steps: OnboardingStepDef[] = useMemo(() => { - const base: OnboardingStepDef[] = [ - { key: 'role', title: 'Your role' }, - { key: 'org', title: 'Organisation' }, + const base: OnboardingStepDef[] = [{ key: 'role', title: 'Your role' }] + if (!hasExistingOrg) base.push({ key: 'org', title: 'Organisation' }) + base.push( { key: 'project', title: 'Project' }, { key: 'feature', title: 'First feature' }, - ] + ) if (selectedRole === 'other') return base return [...base, { key: 'evaluation', title: evaluationStepTitle }] - }, [evaluationStepTitle, selectedRole]) - - // Post-onboarding destination. Real impl needs projectId + environmentKey - // returned from the API-chain stub in handleFinish — both are currently - // placeholders so the URL won't fully resolve until that lands. - const featuresUrl = (envKey: string) => - envKey && effectiveProjectName - ? `/project/${effectiveProjectName}/environment/${envKey}/features` + }, [evaluationStepTitle, hasExistingOrg, selectedRole]) + + // Step navigation is driven by the `steps` array so it stays correct as + // steps are added/removed (org skip, role branching) — no hardcoded + // next/previous keys to keep in sync. + const goToAdjacentStep = (offset: number) => { + const index = steps.findIndex((s) => s.key === step) + const target = steps[index + offset] + if (target) setStep(target.key) + } + const goNext = () => goToAdjacentStep(1) + const goBack = () => goToAdjacentStep(-1) + + // Post-onboarding destination. The features route expects the numeric + // project id and the environment's api_key (not names). + const featuresUrl = (pid: number | null, envKey: string) => + pid && envKey + ? `/project/${pid}/environment/${envKey}/features` : '/organisations' - const finishedDestination = featuresUrl(environmentKey) + const finishedDestination = featuresUrl(projectId, environmentKey) const handleFinish = async () => { setIsSubmitting(true) + setSubmitError(null) trackEvent('question_step.submitted', { featureName, orgName, projectName: effectiveProjectName, role: selectedRole, }) - // POC stub — real impl would chain createOrganisation → createProject → - // createFeature → fetch environment key here. - setEnvironmentKey(DEMO_ENVIRONMENT_KEY) - setIsSubmitting(false) - - // 'other' role skips the AHA step entirely and lands on the features - // page — per the per-role paths design, Other = "orient, don't commit". - // Build the URL inline because the freshly-set environmentKey isn't - // visible in this closure yet. - if (selectedRole === 'other') { - history.push(featuresUrl(DEMO_ENVIRONMENT_KEY)) - return + + try { + // 1. Organisation — reuse the selected one, otherwise create + select. + let organisationId = selectedOrganisation?.id + if (!organisationId) { + const org = await createOrganisation({ name: orgName }).unwrap() + organisationId = org.id + dispatch(setSelectedOrganisationId(org.id)) + } + + // 2. Project. + const project = await createProject({ + name: effectiveProjectName, + organisation: organisationId, + }).unwrap() + + // 3. Environments — Development (where we land) then Production, matching + // what normal project creation produces. + const devEnvironment = await createEnvironment({ + name: 'Development', + project: project.id, + }).unwrap() + await createEnvironment({ + name: 'Production', + project: project.id, + }).unwrap() + + // 4. First feature flag. The create endpoint only needs a small subset + // of ProjectFlag; the request type models the full entity, so assert it. + const featureBody: Partial = { + name: featureName, + project: project.id, + type: 'STANDARD', + } + await createProjectFlag({ + body: featureBody as Req['createProjectFlag']['body'], + project_id: project.id, + }).unwrap() + + // 5. Refresh the legacy organisation store so the shell (project list, + // switcher) picks up the freshly created project without a reload. + AppActions.refreshOrganisation() + + setProjectId(project.id) + setEnvironmentKey(devEnvironment.api_key) + trackEvent('setup.completed', { + projectId: project.id, + role: selectedRole, + }) + + // 'other' role skips the AHA step entirely and lands on the features + // page. Build the URL inline because the freshly-set state isn't visible + // in this closure yet. + if (selectedRole === 'other') { + history.push(featuresUrl(project.id, devEnvironment.api_key)) + return + } + setStep('evaluation') + } catch (error) { + trackEvent('setup.failed', { role: selectedRole }) + setSubmitError(error) + } finally { + setIsSubmitting(false) } - setStep('evaluation') } const handleSkip = () => { @@ -189,16 +275,12 @@ const OnboardingQuickstartPage: FC = () => { setSelectedRole(role) trackEvent('role.selected', { role }) }} - onNext={() => setStep('org')} + onNext={goNext} /> )} {step === 'org' && ( - setStep('project')} - /> + )} {step === 'project' && ( @@ -206,21 +288,28 @@ const OnboardingQuickstartPage: FC = () => { value={projectName} placeholder={defaults.projectName} onChange={setProjectName} - onBack={() => setStep('org')} - onNext={() => setStep('feature')} + onBack={goBack} + onNext={goNext} /> )} {step === 'feature' && ( - setStep('project')} - onFinish={handleFinish} - /> + <> + + {!!submitError && ( +
+ +
+ )} + )} {step === 'evaluation' && From 511af01f65017f7ea7845e0a99eae921750642e4 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 10:40:04 -0300 Subject: [PATCH 07/21] feat(onboarding-quickstart): route no-org signups into the flow + fix org-create coherence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When onboarding_quickstart_flow is enabled, post-signup users with no organisation are routed to /getting-started (the new flow creates the org as its first step) instead of the legacy /create page. Flag off = unchanged /create behaviour. Also fixes the create-org path to go through the account store (AppActions.createOrganisation + select) rather than the RTK mutation. Much of the shell still reads the current organisation from AccountStore, which a pure-RTK create leaves empty — verified end-to-end that this caused org-scoped calls to fire with undefined/garbage ids. Routing via the account store populates it so the new org id flows correctly. Verified on staging: fresh signup -> /getting-started -> create org -> project -> Development+Production envs -> first flag -> features page, with the new org/project coherent in the shell. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/web/components/App.js | 11 +++- .../OnboardingQuickstartPage.tsx | 52 ++++++++++++++++--- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index eba77aab7ab1..1c65893b34d8 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -136,8 +136,15 @@ const App = class extends Component { } if (!AccountStore.getOrganisation() && !invite) { - // If user has no organisation redirect to /create - this.props.history.replace(`/create${query}`) + // If user has no organisation redirect to /create — unless the new + // onboarding flow is enabled, which creates the organisation as its + // first step at /getting-started. + const noOrgDestination = Utils.getFlagsmithHasFeature( + 'onboarding_quickstart_flow', + ) + ? '/getting-started' + : '/create' + this.props.history.replace(`${noOrgDestination}${query}`) return } diff --git a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx index 5757476d89b4..80de25c2e1ec 100644 --- a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx @@ -4,12 +4,12 @@ import { useDispatch } from 'react-redux' import Button from 'components/base/forms/Button' import ErrorMessage from 'components/ErrorMessage' import { useGetProfileQuery } from 'common/services/useProfile' -import { useCreateOrganisationMutation } from 'common/services/useOrganisation' +import { organisationService } from 'common/services/useOrganisation' import { useCreateProjectMutation } from 'common/services/useProject' import { useCreateEnvironmentMutation } from 'common/services/useEnvironment' import { useCreateProjectFlagMutation } from 'common/services/useProjectFlag' import useSelectedOrganisation from 'common/hooks/useSelectedOrganisation' -import { setSelectedOrganisationId } from 'common/selectedOrganisationSlice' +import AccountStore from 'common/stores/account-store' import AppActions from 'common/dispatcher/app-actions' import { Req } from 'common/types/requests' import { ProjectFlag } from 'common/types/responses' @@ -38,6 +38,41 @@ const trackEvent = (event: string, attributes: Record = {}) => // eslint-disable-next-line no-console console.info(`[onboarding.quickstart] ${event}`, attributes) +/** + * Create an organisation through the legacy account store rather than the RTK + * mutation. Much of the shell still reads the current organisation from + * `AccountStore`, and only this path populates it (adds to + * `AccountStore.model.organisations` and sets the selection). A pure-RTK create + * leaves `AccountStore.getOrganisation()` empty, which breaks org-scoped calls + * on the destination page. Resolves with the new organisation id. Mirrors the + * save/select handling in CreateOrganisationPage. + */ +const createOrganisationViaAccountStore = (name: string): Promise => + new Promise((resolve, reject) => { + const cleanup = () => { + AccountStore.off('saved', onSaved) + AccountStore.off('problem', onProblem) + clearTimeout(timer) + } + const onSaved = () => { + cleanup() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore — savedId is set by the createOrganisation controller + resolve(AccountStore.savedId as number) + } + const onProblem = () => { + cleanup() + reject(AccountStore.error || new Error('Failed to create organisation')) + } + const timer = setTimeout(() => { + cleanup() + reject(new Error('Timed out creating organisation')) + }, 20000) + AccountStore.on('saved', onSaved) + AccountStore.on('problem', onProblem) + AppActions.createOrganisation(name) + }) + const OnboardingQuickstartPage: FC = () => { const history = useHistory() const dispatch = useDispatch() @@ -53,7 +88,6 @@ const OnboardingQuickstartPage: FC = () => { const selectedOrganisation = useSelectedOrganisation() const hasExistingOrg = !!selectedOrganisation - const [createOrganisation] = useCreateOrganisationMutation() const [createProject] = useCreateProjectMutation() const [createEnvironment] = useCreateEnvironmentMutation() const [createProjectFlag] = useCreateProjectFlagMutation() @@ -156,9 +190,15 @@ const OnboardingQuickstartPage: FC = () => { // 1. Organisation — reuse the selected one, otherwise create + select. let organisationId = selectedOrganisation?.id if (!organisationId) { - const org = await createOrganisation({ name: orgName }).unwrap() - organisationId = org.id - dispatch(setSelectedOrganisationId(org.id)) + organisationId = await createOrganisationViaAccountStore(orgName) + // Select it (sets the cookie, the Redux slice and AccountStore's + // current org) and refresh the RTK organisation list. + AppActions.selectOrganisation(organisationId) + dispatch( + organisationService.util.invalidateTags([ + { id: 'LIST', type: 'Organisation' }, + ]), + ) } // 2. Project. From 997204d91745317636e44264fb2d4d55556657a8 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 13:44:54 -0300 Subject: [PATCH 08/21] feat(onboarding-quickstart): gate the flow on the real feature flag Removes the FORCE_ON local-validation override so GettingStartedSwitch gates on Utils.getFlagsmithHasFeature('onboarding_quickstart_flow'). Until the flag exists/is enabled on Flagsmith-on-Flagsmith, the check returns false and the existing GettingStartedPage renders (safe default). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BACKEND_INTEGRATION_PLAN.md | 17 ++++++++++++++--- .../GettingStartedSwitch.tsx | 15 ++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md index e91a789acba6..283facea7266 100644 --- a/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md +++ b/frontend/web/components/pages/onboarding-quickstart/BACKEND_INTEGRATION_PLAN.md @@ -77,9 +77,20 @@ legacy `AccountStore`-reading surfaces coherent. - **Self-hosted / permissions**: org creation may be restricted; surface a clear message rather than a raw error. -### Phase 5 — Flag + cleanup -- Revert `GettingStartedSwitch` `FORCE_ON = true` to the real - `Utils.getFlagsmithHasFeature('onboarding_quickstart_flow')` gate. +### Phase 5 — Flag + cleanup — DONE (flag must exist on FoF) +- `GettingStartedSwitch` now gates on + `Utils.getFlagsmithHasFeature('onboarding_quickstart_flow')` — the + `FORCE_ON` override is removed. The no-org post-signup routing + (`App.js`) gates on the same flag. +- **Still required:** create + enable `onboarding_quickstart_flow` on + Flagsmith-on-Flagsmith. Until it exists, the flag reads false and the + old `GettingStartedPage` + `/create` flow render (safe default). + +### Known follow-ups (not blockers for a flagged rollout) +- No-org users landing on `/getting-started` trigger the shell's + `OrganisationStore.getOrganisation()` bootstrap with no org id, firing + harmless failed calls (`get-subscription-metadata` etc.). Dev-only red + overlay; silent in production. Fix would guard the shared store. ## Out of scope - First-eval signal (stays mocked — see `FIRST_EVAL_BACKEND_PLAN.md`). diff --git a/frontend/web/components/pages/onboarding-quickstart/GettingStartedSwitch.tsx b/frontend/web/components/pages/onboarding-quickstart/GettingStartedSwitch.tsx index 4edf444ff236..f0bff96f9f27 100644 --- a/frontend/web/components/pages/onboarding-quickstart/GettingStartedSwitch.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/GettingStartedSwitch.tsx @@ -3,14 +3,15 @@ import Utils from 'common/utils/utils' import GettingStartedPage from 'web/components/pages/GettingStartedPage' import OnboardingQuickstartPage from 'web/components/pages/onboarding-quickstart/OnboardingQuickstartPage' -// TEMP: forced on for local validation. Revert to `Utils.getFlagsmithHasFeature(...)` -// before merge so the flag actually gates the flow. -const FORCE_ON = true - const GettingStartedSwitch: FC = () => { - const ahaEnabled = - FORCE_ON || Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') - return ahaEnabled ? : + const quickstartEnabled = Utils.getFlagsmithHasFeature( + 'onboarding_quickstart_flow', + ) + return quickstartEnabled ? ( + + ) : ( + + ) } export default GettingStartedSwitch From 650433e284d447d00f6ed60273254cdc4b5b4e7f Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 13:46:44 -0300 Subject: [PATCH 09/21] feat(onboarding-quickstart): make the create chain idempotent on retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks entities already created (org, project, Dev/Prod envs, feature) in a ref so that retrying after a partial failure — e.g. project created but an environment call failed — reuses them instead of creating duplicates. On a fresh run the ref is empty and every step runs as before; only retries skip already-completed steps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../OnboardingQuickstartPage.tsx | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx index 80de25c2e1ec..8470e3ebab81 100644 --- a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx @@ -109,6 +109,18 @@ const OnboardingQuickstartPage: FC = () => { const [projectId, setProjectId] = useState(null) const [environmentKey, setEnvironmentKey] = useState('') + // Tracks entities already created so a retry after a partial failure (e.g. + // project created but an environment call failed) reuses them rather than + // creating duplicates. A deliberate Back + rename after a step succeeded + // reuses the original — acceptable, since retries follow failures. + const createdRef = useRef<{ + orgId?: number + projectId?: number + devKey?: string + prodDone?: boolean + featureDone?: boolean + }>({}) + // Org name is pre-filled from the email domain — it's meaningful data the // user can keep or edit. The project name is NOT pre-filled: 'My first // project' is shown as placeholder text only (see ProjectStep), so the user @@ -186,9 +198,10 @@ const OnboardingQuickstartPage: FC = () => { role: selectedRole, }) + const created = createdRef.current try { // 1. Organisation — reuse the selected one, otherwise create + select. - let organisationId = selectedOrganisation?.id + let organisationId = selectedOrganisation?.id ?? created.orgId if (!organisationId) { organisationId = await createOrganisationViaAccountStore(orgName) // Select it (sets the cookie, the Redux slice and AccountStore's @@ -199,45 +212,62 @@ const OnboardingQuickstartPage: FC = () => { { id: 'LIST', type: 'Organisation' }, ]), ) + created.orgId = organisationId } // 2. Project. - const project = await createProject({ - name: effectiveProjectName, - organisation: organisationId, - }).unwrap() + let newProjectId = created.projectId + if (!newProjectId) { + const project = await createProject({ + name: effectiveProjectName, + organisation: organisationId, + }).unwrap() + newProjectId = project.id + created.projectId = project.id + } // 3. Environments — Development (where we land) then Production, matching // what normal project creation produces. - const devEnvironment = await createEnvironment({ - name: 'Development', - project: project.id, - }).unwrap() - await createEnvironment({ - name: 'Production', - project: project.id, - }).unwrap() + let devKey = created.devKey + if (!devKey) { + const devEnvironment = await createEnvironment({ + name: 'Development', + project: newProjectId, + }).unwrap() + devKey = devEnvironment.api_key + created.devKey = devKey + } + if (!created.prodDone) { + await createEnvironment({ + name: 'Production', + project: newProjectId, + }).unwrap() + created.prodDone = true + } // 4. First feature flag. The create endpoint only needs a small subset // of ProjectFlag; the request type models the full entity, so assert it. - const featureBody: Partial = { - name: featureName, - project: project.id, - type: 'STANDARD', + if (!created.featureDone) { + const featureBody: Partial = { + name: featureName, + project: newProjectId, + type: 'STANDARD', + } + await createProjectFlag({ + body: featureBody as Req['createProjectFlag']['body'], + project_id: newProjectId, + }).unwrap() + created.featureDone = true } - await createProjectFlag({ - body: featureBody as Req['createProjectFlag']['body'], - project_id: project.id, - }).unwrap() // 5. Refresh the legacy organisation store so the shell (project list, // switcher) picks up the freshly created project without a reload. AppActions.refreshOrganisation() - setProjectId(project.id) - setEnvironmentKey(devEnvironment.api_key) + setProjectId(newProjectId) + setEnvironmentKey(devKey) trackEvent('setup.completed', { - projectId: project.id, + projectId: newProjectId, role: selectedRole, }) @@ -245,7 +275,7 @@ const OnboardingQuickstartPage: FC = () => { // page. Build the URL inline because the freshly-set state isn't visible // in this closure yet. if (selectedRole === 'other') { - history.push(featuresUrl(project.id, devEnvironment.api_key)) + history.push(featuresUrl(newProjectId, devKey)) return } setStep('evaluation') From 27a1c726f061949cee04941f6bea81f8cd79bc4a Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 14:02:00 -0300 Subject: [PATCH 10/21] fix(onboarding-quickstart): require a project name before advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project step let Next stay enabled while the field was empty (it fell back to the placeholder), yet still rendered the empty field with the red invalid border — a contradiction. Require a name instead: Next is disabled until one is entered, so the red border correctly signals "needs input". The placeholder remains a hint, not a fallback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../OnboardingQuickstartPage.tsx | 5 +++-- .../components/ProjectStep.tsx | 13 ++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx index 8470e3ebab81..b5ca6e66d9a3 100644 --- a/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/OnboardingQuickstartPage.tsx @@ -124,8 +124,9 @@ const OnboardingQuickstartPage: FC = () => { // Org name is pre-filled from the email domain — it's meaningful data the // user can keep or edit. The project name is NOT pre-filled: 'My first // project' is shown as placeholder text only (see ProjectStep), so the user - // doesn't have to clear a generic default before typing their own. When left - // blank we fall back to the placeholder via `effectiveProjectName`. + // doesn't have to clear a generic default before typing their own. A name is + // required (ProjectStep disables Next until one is entered); the trim here is + // a defensive guard against a whitespace-only value. useEffect(() => { setOrgName((existing) => existing || defaults.orgName) }, [defaults.orgName]) diff --git a/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx b/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx index 721800ae65b8..eb9479b4811b 100644 --- a/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/components/ProjectStep.tsx @@ -18,10 +18,9 @@ const ProjectStep: FC = ({ placeholder, value, }) => { - const hasValue = !!value.trim() - // The placeholder ('My first project') is a real fallback — leaving the - // field blank uses it, so the user can advance without typing anything. - const canProceed = hasValue || !!placeholder + // A name is required. The placeholder ('My first project') is only a hint — + // it is not used as a fallback, so the user must type something to advance. + const isValid = !!value.trim() return ( = ({ className: 'w-50', name: 'projectName', onKeyDown: (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && canProceed) { + if (e.key === 'Enter' && isValid) { e.preventDefault() onNext() } @@ -45,7 +44,7 @@ const ProjectStep: FC = ({ onChange={(e: React.ChangeEvent) => onChange(e.target.value) } - isValid={hasValue} + isValid={isValid} /> } footer={ @@ -53,7 +52,7 @@ const ProjectStep: FC = ({ - From f5d8b7324b35d1d532def83bff51f0ce5991b5a1 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 14:10:43 -0300 Subject: [PATCH 11/21] feat(onboarding-quickstart): gate "Invite a teammate" by plan and deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per prototype feedback: only surface the invite CTA where it makes sense — self-hosted (any plan) or paid SaaS. On the free SaaS plan it's hidden rather than shown with an upgrade nudge, which would be friction at the success moment. When hidden, "Explore the dashboard" becomes the primary CTA and the supporting copy drops its invite reference (success panel and the PM intro paragraph). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/FeatureEvaluationStep.tsx | 30 +++++++++++++--- .../components/SuccessActions.tsx | 35 ++++++++++++++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/frontend/web/components/pages/onboarding-quickstart/components/FeatureEvaluationStep.tsx b/frontend/web/components/pages/onboarding-quickstart/components/FeatureEvaluationStep.tsx index 1e2ca152a208..91865774c6b3 100644 --- a/frontend/web/components/pages/onboarding-quickstart/components/FeatureEvaluationStep.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/components/FeatureEvaluationStep.tsx @@ -2,7 +2,8 @@ import React, { FC, useEffect, useMemo, useState } from 'react' import { uniqBy } from 'lodash' import Button from 'components/base/forms/Button' import Constants from 'common/constants' -import Utils from 'common/utils/utils' +import Utils, { planNames } from 'common/utils/utils' +import AccountStore from 'common/stores/account-store' import { IntegrationSummary } from 'components/pages/IntegrationsPage' import StatusPanel from 'web/components/pages/onboarding-quickstart/components/StatusPanel' import SuccessActions from 'web/components/pages/onboarding-quickstart/components/SuccessActions' @@ -33,6 +34,13 @@ const FeatureEvaluationStep: FC = ({ const [toggleValue, setToggleValue] = useState(false) const isReceived = state === 'received' + // Only surface "Invite a teammate" where it makes sense: self-hosted (any + // plan) or paid SaaS. On the free SaaS plan we hide it rather than nudge an + // upgrade, which would just be friction at this moment. + const canInvite = + !Utils.isSaas() || + Utils.getPlanName(AccountStore.getActiveOrgPlan()) !== planNames.free + // Same data merge as `components/IntegrationSelect.tsx` — Flagsmith // remote-config flag (`integration_data`) concatenated with the // hardcoded constants list, deduped by title. Read-only render here: @@ -69,8 +77,10 @@ const FeatureEvaluationStep: FC = ({

{projectName} and{' '} {featureName} are set up. - Flagsmith plugs into the tools your team already uses — invite a - teammate to wire it into your codebase. + Flagsmith plugs into the tools your team already uses + {canInvite + ? ' — invite a teammate to wire it into your codebase.' + : '.'}

@@ -95,7 +105,12 @@ const FeatureEvaluationStep: FC = ({ - + ) } @@ -129,7 +144,12 @@ const FeatureEvaluationStep: FC = ({ {isReceived && ( - + )} {!isReceived && ( diff --git a/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx b/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx index 81ceb7f106cd..4771631fdddd 100644 --- a/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx +++ b/frontend/web/components/pages/onboarding-quickstart/components/SuccessActions.tsx @@ -4,6 +4,7 @@ import Icon from 'components/icons/Icon' import { OnboardingRoleKey } from 'web/components/pages/onboarding-quickstart/data/roles' type SuccessActionsProps = { + canInvite: boolean onExplore: () => void onInvite: () => void role: OnboardingRoleKey @@ -11,13 +12,20 @@ type SuccessActionsProps = { const COPY_BY_ROLE: Record< OnboardingRoleKey, - { heading: string; subtitle: string; inviteLabel: string } + { + heading: string + subtitle: string + subtitleNoInvite: string + inviteLabel: string + } > = { engineer: { heading: "Nice — you've shipped your first eval.", inviteLabel: 'Invite a teammate', subtitle: 'Get the rest of your team in so they can target users and roll out gradually.', + subtitleNoInvite: + 'Head to your dashboard to target users and roll out your flag gradually.', }, // 'other' never reaches this component — they skip AHA — but typing it // here keeps the Record exhaustive in case the routing changes. @@ -25,6 +33,7 @@ const COPY_BY_ROLE: Record< heading: "You're in.", inviteLabel: 'Invite a teammate', subtitle: 'Explore the dashboard or invite teammates when you’re ready.', + subtitleNoInvite: 'Explore the dashboard whenever you’re ready.', }, pm: { @@ -32,10 +41,13 @@ const COPY_BY_ROLE: Record< inviteLabel: 'Invite a teammate', subtitle: 'Invite a teammate to wire Flagsmith into your codebase and ship the first flag.', + subtitleNoInvite: + 'Explore the dashboard and wire Flagsmith into your codebase.', }, } const SuccessActions: FC = ({ + canInvite, onExplore, onInvite, role, @@ -45,16 +57,21 @@ const SuccessActions: FC = ({

{copy.heading}

-

{copy.subtitle}

+

+ {canInvite ? copy.subtitle : copy.subtitleNoInvite} +

- - + )} + {/* When invite is hidden, Explore is the only and primary CTA. */} +
From 5f51a6fb6292d43afb90aa13d74fc134f0b9dd52 Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 17:00:40 -0300 Subject: [PATCH 12/21] feat(onboarding-quickstart): hide marketing announcement banners during the flow The quickstart onboarding is a focused surface; the global Announcement / AnnouncementPerPage promo banners (e.g. event/workshop CTAs) are a distraction there. Suppress them while on /getting-started when the quickstart flow is enabled. Other pages and the legacy getting-started page are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/web/components/App.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 1c65893b34d8..426e205b893c 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -232,6 +232,12 @@ const App = class extends Component { const projectId = this.getProjectId(this.props) const environmentId = this.getEnvironmentId(this.props) + // The quickstart onboarding flow is a focused, distraction-free surface — + // suppress the marketing announcement banners while the user is in it. + const isOnboardingFlow = + pathname === '/getting-started' && + Utils.getFlagsmithHasFeature('onboarding_quickstart_flow') + if ( AccountStore.getOrganisation() && AccountStore.getOrganisation().block_access_to_admin && @@ -316,12 +322,14 @@ const App = class extends Component { AccountStore.getOrganisation()?.subscription.plan } /> -
-
- - + {!isOnboardingFlow && ( +
+
+ + +
-
+ )} )} From a73bf8f3d19805ae1edf3a44b80761a7993b111a Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 29 May 2026 17:05:16 -0300 Subject: [PATCH 13/21] feat(onboarding-quickstart): render the flow chromeless (no app nav) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart onboarding is a focused, isolated surface — render only the flow, bypassing the Nav shell (top bar, sidebar, project/account/docs links) so the customer can't navigate away mid-onboarding. The flow keeps its own explicit "Skip — set up manually" escape. Only applies on /getting-started when the quickstart flag is enabled; every other route renders the full shell as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/web/components/App.js | 60 ++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/frontend/web/components/App.js b/frontend/web/components/App.js index 426e205b893c..e2a584ac5387 100644 --- a/frontend/web/components/App.js +++ b/frontend/web/components/App.js @@ -286,24 +286,36 @@ const App = class extends Component { onLogin={this.onLogin} > {({ isSaving, user }, { twoFactorLogin }) => { - return user && user.twoFactorPrompt ? ( -
- { - this.setState({ error: false }) - twoFactorLogin(this.state.pin, () => { - this.setState({ error: true }) - }) - }} - isLoading={isSaving} - onChange={(e) => - this.setState({ pin: Utils.safeParseEventValue(e) }) - } - /> -
- ) : ( + if (user && user.twoFactorPrompt) { + return ( +
+ { + this.setState({ error: false }) + twoFactorLogin(this.state.pin, () => { + this.setState({ error: true }) + }) + }} + isLoading={isSaving} + onChange={(e) => + this.setState({ pin: Utils.safeParseEventValue(e) }) + } + /> +
+ ) + } + + // Chromeless onboarding: render only the flow — no nav, sidebar, + // or header links — so the customer can't navigate away mid-flow. + // The flow provides its own explicit "Skip — set up manually" + // escape. + if (isOnboardingFlow) { + return
{this.props.children}
+ } + + return (