diff --git a/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx index f4735aedc3..489fc692ef 100644 --- a/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx +++ b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx @@ -7,6 +7,8 @@ import OnboardingForm from "#/routes/onboarding-form"; const mockMutate = vi.fn(); const mockNavigate = vi.fn(); +const mockUseConfig = vi.fn(); +const mockTrackOnboardingCompleted = vi.fn(); vi.mock("react-router", async (importOriginal) => { const original = await importOriginal(); @@ -22,6 +24,16 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({ }), })); +vi.mock("#/hooks/query/use-config", () => ({ + useConfig: () => mockUseConfig(), +})); + +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackOnboardingCompleted: mockTrackOnboardingCompleted, + }), +})); + const renderOnboardingForm = () => { return renderWithProviders( @@ -30,10 +42,15 @@ const renderOnboardingForm = () => { ); }; -describe("OnboardingForm", () => { +describe("OnboardingForm - SaaS Mode", () => { beforeEach(() => { mockMutate.mockClear(); mockNavigate.mockClear(); + mockTrackOnboardingCompleted.mockClear(); + mockUseConfig.mockReturnValue({ + data: { app_mode: "saas" }, + isLoading: false, + }); }); it("should render with the correct test id", () => { @@ -50,7 +67,7 @@ describe("OnboardingForm", () => { expect(screen.getByTestId("step-actions")).toBeInTheDocument(); }); - it("should display step progress indicator with 3 bars", () => { + it("should display step progress indicator with 3 bars for saas mode", () => { renderOnboardingForm(); const stepHeader = screen.getByTestId("step-header"); @@ -69,7 +86,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); const nextButton = screen.getByRole("button", { name: /next/i }); expect(nextButton).not.toBeDisabled(); @@ -84,7 +101,7 @@ describe("OnboardingForm", () => { let progressBars = stepHeader.querySelectorAll(".bg-white"); expect(progressBars).toHaveLength(1); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 2, first two progress bars should be filled @@ -96,7 +113,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const nextButton = screen.getByRole("button", { name: /next/i }); @@ -107,29 +124,51 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - // Step 1 - select role - await user.click(screen.getByTestId("step-option-software_engineer")); - await user.click(screen.getByRole("button", { name: /next/i })); - - // Step 2 - select org size + // Step 1 - select org size (first step in saas mode - single select) await user.click(screen.getByTestId("step-option-org_2_10")); await user.click(screen.getByRole("button", { name: /next/i })); - // Step 3 - select use case + // Step 2 - select use case (multi-select) await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role (last step in saas mode - single select) + await user.click(screen.getByTestId("step-option-software_engineer")); await user.click(screen.getByRole("button", { name: /finish/i })); expect(mockMutate).toHaveBeenCalledTimes(1); expect(mockMutate).toHaveBeenCalledWith({ selections: { - step1: "software_engineer", - step2: "org_2_10", - step3: "new_features", + org_size: "org_2_10", + use_case: ["new_features"], + role: "software_engineer", }, }); }); - it("should render 6 options on step 1", () => { + it("should track onboarding completion to PostHog in SaaS mode", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Complete the full SaaS onboarding flow + await user.click(screen.getByTestId("step-option-org_2_10")); + await user.click(screen.getByRole("button", { name: /next/i })); + + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByRole("button", { name: /next/i })); + + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1); + expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({ + role: "software_engineer", + orgSize: "org_2_10", + useCase: ["new_features"], + }); + }); + + it("should render 5 options on step 1 (org size question)", () => { renderOnboardingForm(); const options = screen @@ -137,31 +176,86 @@ describe("OnboardingForm", () => { .filter((btn) => btn.getAttribute("data-testid")?.startsWith("step-option-"), ); - expect(options).toHaveLength(6); + expect(options).toHaveLength(5); }); it("should preserve selections when navigating through steps", async () => { const user = userEvent.setup(); renderOnboardingForm(); - // Select role on step 1 - await user.click(screen.getByTestId("step-option-cto_founder")); - await user.click(screen.getByRole("button", { name: /next/i })); - - // Select org size on step 2 + // Select org size on step 1 (single select) await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); - // Select use case on step 3 + // Select use case on step 2 (multi-select) await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Select role on step 3 (single select) + await user.click(screen.getByTestId("step-option-cto_founder")); await user.click(screen.getByRole("button", { name: /finish/i })); // Verify all selections were preserved expect(mockMutate).toHaveBeenCalledWith({ selections: { - step1: "cto_founder", - step2: "solo", - step3: "fixing_bugs", + org_size: "solo", + use_case: ["fixing_bugs"], + role: "cto_founder", + }, + }); + }); + + it("should allow selecting multiple options on multi-select steps", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Step 1 - select org size (single select) + await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 2 - select multiple use cases (multi-select) + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByTestId("step-option-refactoring")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role (single select) + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockMutate).toHaveBeenCalledWith({ + selections: { + org_size: "solo", + use_case: ["new_features", "fixing_bugs", "refactoring"], + role: "software_engineer", + }, + }); + }); + + it("should allow deselecting options on multi-select steps", async () => { + const user = userEvent.setup(); + renderOnboardingForm(); + + // Step 1 - select org size + await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 2 - select and deselect use cases + await user.click(screen.getByTestId("step-option-new_features")); + await user.click(screen.getByTestId("step-option-fixing_bugs")); + await user.click(screen.getByTestId("step-option-new_features")); // Deselect + + await user.click(screen.getByRole("button", { name: /next/i })); + + // Step 3 - select role + await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByRole("button", { name: /finish/i })); + + expect(mockMutate).toHaveBeenCalledWith({ + selections: { + org_size: "solo", + use_case: ["fixing_bugs"], + role: "software_engineer", }, }); }); @@ -171,10 +265,10 @@ describe("OnboardingForm", () => { renderOnboardingForm(); // Navigate to step 3 - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); - await user.click(screen.getByTestId("step-option-solo")); + await user.click(screen.getByTestId("step-option-new_features")); await user.click(screen.getByRole("button", { name: /next/i })); // On step 3, all three progress bars should be filled @@ -194,7 +288,7 @@ describe("OnboardingForm", () => { const user = userEvent.setup(); renderOnboardingForm(); - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); const backButton = screen.getByRole("button", { name: /back/i }); @@ -206,7 +300,7 @@ describe("OnboardingForm", () => { renderOnboardingForm(); // Navigate to step 2 - await user.click(screen.getByTestId("step-option-software_engineer")); + await user.click(screen.getByTestId("step-option-solo")); await user.click(screen.getByRole("button", { name: /next/i })); // Verify we're on step 2 (2 progress bars filled) diff --git a/frontend/__tests__/components/features/onboarding/step-content.test.tsx b/frontend/__tests__/components/features/onboarding/step-content.test.tsx index e8ac37eda4..8f0803cb7c 100644 --- a/frontend/__tests__/components/features/onboarding/step-content.test.tsx +++ b/frontend/__tests__/components/features/onboarding/step-content.test.tsx @@ -12,7 +12,7 @@ describe("StepContent", () => { const defaultProps = { options: mockOptions, - selectedOptionId: null, + selectedOptionIds: [], onSelectOption: vi.fn(), }; @@ -44,7 +44,7 @@ describe("StepContent", () => { }); it("should mark the selected option as selected", () => { - render(); + render(); const selectedOption = screen.getByTestId("step-option-option1"); const unselectedOption = screen.getByTestId("step-option-option2"); diff --git a/frontend/__tests__/components/features/onboarding/step-input.test.tsx b/frontend/__tests__/components/features/onboarding/step-input.test.tsx new file mode 100644 index 0000000000..a4f388efbb --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/step-input.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { StepInput } from "#/components/features/onboarding/step-input"; + +describe("StepInput", () => { + const defaultProps = { + id: "test-input", + label: "Test Label", + value: "", + onChange: vi.fn(), + }; + + it("should render with correct test id", () => { + render(); + + expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument(); + }); + + it("should render the label", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("should display the provided value", () => { + render(); + + const input = screen.getByTestId("step-input-test-input"); + expect(input).toHaveValue("Hello World"); + }); + + it("should call onChange when user types", async () => { + const mockOnChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("step-input-test-input"); + await user.type(input, "a"); + + expect(mockOnChange).toHaveBeenCalledWith("a"); + }); + + it("should call onChange with the full input value on each keystroke", async () => { + const mockOnChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("step-input-test-input"); + await user.type(input, "abc"); + + expect(mockOnChange).toHaveBeenCalledTimes(3); + expect(mockOnChange).toHaveBeenNthCalledWith(1, "a"); + expect(mockOnChange).toHaveBeenNthCalledWith(2, "b"); + expect(mockOnChange).toHaveBeenNthCalledWith(3, "c"); + }); + + it("should use the id prop for data-testid", () => { + render(); + + expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument(); + }); + + it("should render as a text input", () => { + render(); + + const input = screen.getByTestId("step-input-test-input"); + expect(input).toHaveAttribute("type", "text"); + }); +}); diff --git a/frontend/src/components/features/onboarding/step-content.tsx b/frontend/src/components/features/onboarding/step-content.tsx index 658b8f63c0..3f99e54389 100644 --- a/frontend/src/components/features/onboarding/step-content.tsx +++ b/frontend/src/components/features/onboarding/step-content.tsx @@ -1,35 +1,56 @@ import { StepOption } from "./step-option"; +import { StepInput } from "./step-input"; export interface Option { id: string; label: string; } +export interface InputField { + id: string; + label: string; +} + interface StepContentProps { - options: Option[]; - selectedOptionId: string | null; + options?: Option[]; + inputFields?: InputField[]; + selectedOptionIds: string[]; + inputValues?: Record; onSelectOption: (optionId: string) => void; + onInputChange?: (fieldId: string, value: string) => void; } export function StepContent({ options, - selectedOptionId, + inputFields, + selectedOptionIds, + inputValues = {}, onSelectOption, + onInputChange, }: StepContentProps) { return (
- {options.map((option) => ( + {options?.map((option) => ( onSelectOption(option.id)} /> ))} + {inputFields?.map((field) => ( + onInputChange?.(field.id, value)} + /> + ))}
); } diff --git a/frontend/src/components/features/onboarding/step-header.tsx b/frontend/src/components/features/onboarding/step-header.tsx index bf1e458ec7..a0aa4044b2 100644 --- a/frontend/src/components/features/onboarding/step-header.tsx +++ b/frontend/src/components/features/onboarding/step-header.tsx @@ -3,11 +3,17 @@ import { cn } from "#/utils/utils"; interface StepHeaderProps { title: string; + subtitle?: string; currentStep: number; totalSteps: number; } -function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) { +function StepHeader({ + title, + subtitle, + currentStep, + totalSteps, +}: StepHeaderProps) { return (
@@ -24,6 +30,11 @@ function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) { {title} + {subtitle && ( + + {subtitle} + + )}
); } diff --git a/frontend/src/components/features/onboarding/step-input.tsx b/frontend/src/components/features/onboarding/step-input.tsx new file mode 100644 index 0000000000..4364a4f6b0 --- /dev/null +++ b/frontend/src/components/features/onboarding/step-input.tsx @@ -0,0 +1,27 @@ +interface StepInputProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; +} + +export function StepInput({ id, label, value, onChange }: StepInputProps) { + return ( +
+ + onChange(e.target.value)} + className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors" + /> +
+ ); +} diff --git a/frontend/src/constants/onboarding.tsx b/frontend/src/constants/onboarding.tsx new file mode 100644 index 0000000000..3dc7715a8c --- /dev/null +++ b/frontend/src/constants/onboarding.tsx @@ -0,0 +1,101 @@ +import { I18nKey } from "#/i18n/declaration"; + +export type OnboardingAppMode = "saas" | "self-hosted"; + +interface BaseOnboardingQuestion { + id: string; + app_mode: OnboardingAppMode[]; + questionKey: I18nKey; + subtitleKey?: I18nKey; +} + +interface InputQuestion extends BaseOnboardingQuestion { + type: "input"; + inputOptions: { key: I18nKey; id: string }[]; +} + +interface SingleSelectQuestion extends BaseOnboardingQuestion { + type: "single"; + answerOptions: { key: I18nKey; id: string }[]; +} + +interface MultiSelectQuestion extends BaseOnboardingQuestion { + type: "multi"; + answerOptions: { key: I18nKey; id: string }[]; +} + +export type OnboardingQuestion = + | InputQuestion + | SingleSelectQuestion + | MultiSelectQuestion; + +export const ONBOARDING_FORM: OnboardingQuestion[] = [ + { + id: "org_name", + type: "input", + app_mode: ["self-hosted"], + questionKey: I18nKey.ONBOARDING$ORG_NAME_QUESTION, + inputOptions: [ + { key: I18nKey.ONBOARDING$ORG_NAME_INPUT_NAME, id: "org_name" }, + { key: I18nKey.ONBOARDING$ORG_NAME_INPUT_DOMAIN, id: "org_domain" }, + ], + }, + { + id: "org_size", + type: "single", + app_mode: ["saas", "self-hosted"], + questionKey: I18nKey.ONBOARDING$ORG_SIZE_QUESTION, + subtitleKey: I18nKey.ONBOARDING$ORG_SIZE_SUBTITLE, + answerOptions: [ + { key: I18nKey.ONBOARDING$ORG_SIZE_SOLO, id: "solo" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_2_10, id: "org_2_10" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_11_50, id: "org_11_50" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_51_200, id: "org_51_200" }, + { key: I18nKey.ONBOARDING$ORG_SIZE_200_PLUS, id: "org_200_plus" }, + ], + }, + { + id: "use_case", + type: "multi", + app_mode: ["saas", "self-hosted"], + questionKey: I18nKey.ONBOARDING$USE_CASE_QUESTION, + subtitleKey: I18nKey.ONBOARDING$USE_CASE_SUBTITLE, + answerOptions: [ + { key: I18nKey.ONBOARDING$USE_CASE_NEW_FEATURES, id: "new_features" }, + { + key: I18nKey.ONBOARDING$USE_CASE_APP_FROM_SCRATCH, + id: "app_from_scratch", + }, + { key: I18nKey.ONBOARDING$USE_CASE_FIXING_BUGS, id: "fixing_bugs" }, + { key: I18nKey.ONBOARDING$USE_CASE_REFACTORING, id: "refactoring" }, + { + key: I18nKey.ONBOARDING$USE_CASE_AUTOMATING_TASKS, + id: "automating_tasks", + }, + { key: I18nKey.ONBOARDING$USE_CASE_NOT_SURE, id: "not_sure" }, + ], + }, + { + id: "role", + type: "single", + app_mode: ["saas"], + questionKey: I18nKey.ONBOARDING$ROLE_QUESTION, + answerOptions: [ + { + key: I18nKey.ONBOARDING$ROLE_SOFTWARE_ENGINEER, + id: "software_engineer", + }, + { + key: I18nKey.ONBOARDING$ROLE_ENGINEERING_MANAGER, + id: "engineering_manager", + }, + { key: I18nKey.ONBOARDING$ROLE_CTO_FOUNDER, id: "cto_founder" }, + { + key: I18nKey.ONBOARDING$ROLE_PRODUCT_OPERATIONS, + id: "product_operations", + }, + { key: I18nKey.ONBOARDING$ROLE_STUDENT_HOBBYIST, id: "student_hobbyist" }, + { key: I18nKey.ONBOARDING$ROLE_OTHER, id: "other" }, + ], + }, +]; diff --git a/frontend/src/hooks/mutation/use-submit-onboarding.ts b/frontend/src/hooks/mutation/use-submit-onboarding.ts index 63fbebf986..13e9eead10 100644 --- a/frontend/src/hooks/mutation/use-submit-onboarding.ts +++ b/frontend/src/hooks/mutation/use-submit-onboarding.ts @@ -3,7 +3,7 @@ import { useNavigate } from "react-router"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; type SubmitOnboardingArgs = { - selections: Record; + selections: Record; }; export const useSubmitOnboarding = () => { diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts index a168be984b..8cb877f6f9 100644 --- a/frontend/src/hooks/query/use-config.ts +++ b/frontend/src/hooks/query/use-config.ts @@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import OptionService from "#/api/option-service/option-service.api"; import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page"; -export const useConfig = () => { +interface UseConfigOptions { + enabled?: boolean; +} + +export const useConfig = (options?: UseConfigOptions) => { const isOnIntermediatePage = useIsOnIntermediatePage(); return useQuery({ @@ -10,6 +14,6 @@ export const useConfig = () => { queryFn: OptionService.getConfig, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes, - enabled: !isOnIntermediatePage, + enabled: options?.enabled ?? !isOnIntermediatePage, }); }; diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index fea2f29d86..70fc98f810 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -110,9 +110,9 @@ export const useTracking = () => { orgSize, useCase, }: { - role: string; - orgSize: string; - useCase: string; + role?: string; + orgSize?: string; + useCase?: string[]; }) => { posthog.capture("onboarding_completed", { role, diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 51f1bd288c..9d85c9528b 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1113,28 +1113,31 @@ export enum I18nKey { ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND", ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER", ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS", - ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE", - ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE", - ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER", - ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER", - ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER", - ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS", - ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST", - ONBOARDING$OTHER = "ONBOARDING$OTHER", - ONBOARDING$STEP2_TITLE = "ONBOARDING$STEP2_TITLE", - ONBOARDING$SOLO = "ONBOARDING$SOLO", - ONBOARDING$ORG_2_10 = "ONBOARDING$ORG_2_10", - ONBOARDING$ORG_11_50 = "ONBOARDING$ORG_11_50", - ONBOARDING$ORG_51_200 = "ONBOARDING$ORG_51_200", - ONBOARDING$ORG_200_1000 = "ONBOARDING$ORG_200_1000", - ONBOARDING$ORG_1000_PLUS = "ONBOARDING$ORG_1000_PLUS", - ONBOARDING$STEP3_TITLE = "ONBOARDING$STEP3_TITLE", - ONBOARDING$NEW_FEATURES = "ONBOARDING$NEW_FEATURES", - ONBOARDING$APP_FROM_SCRATCH = "ONBOARDING$APP_FROM_SCRATCH", - ONBOARDING$FIXING_BUGS = "ONBOARDING$FIXING_BUGS", - ONBOARDING$REFACTORING = "ONBOARDING$REFACTORING", - ONBOARDING$AUTOMATING_TASKS = "ONBOARDING$AUTOMATING_TASKS", - ONBOARDING$NOT_SURE = "ONBOARDING$NOT_SURE", + ONBOARDING$ORG_NAME_QUESTION = "ONBOARDING$ORG_NAME_QUESTION", + ONBOARDING$ORG_NAME_INPUT_NAME = "ONBOARDING$ORG_NAME_INPUT_NAME", + ONBOARDING$ORG_NAME_INPUT_DOMAIN = "ONBOARDING$ORG_NAME_INPUT_DOMAIN", + ONBOARDING$ORG_SIZE_QUESTION = "ONBOARDING$ORG_SIZE_QUESTION", + ONBOARDING$ORG_SIZE_SUBTITLE = "ONBOARDING$ORG_SIZE_SUBTITLE", + ONBOARDING$ORG_SIZE_SOLO = "ONBOARDING$ORG_SIZE_SOLO", + ONBOARDING$ORG_SIZE_2_10 = "ONBOARDING$ORG_SIZE_2_10", + ONBOARDING$ORG_SIZE_11_50 = "ONBOARDING$ORG_SIZE_11_50", + ONBOARDING$ORG_SIZE_51_200 = "ONBOARDING$ORG_SIZE_51_200", + ONBOARDING$ORG_SIZE_200_PLUS = "ONBOARDING$ORG_SIZE_200_PLUS", + ONBOARDING$USE_CASE_QUESTION = "ONBOARDING$USE_CASE_QUESTION", + ONBOARDING$USE_CASE_SUBTITLE = "ONBOARDING$USE_CASE_SUBTITLE", + ONBOARDING$USE_CASE_NEW_FEATURES = "ONBOARDING$USE_CASE_NEW_FEATURES", + ONBOARDING$USE_CASE_APP_FROM_SCRATCH = "ONBOARDING$USE_CASE_APP_FROM_SCRATCH", + ONBOARDING$USE_CASE_FIXING_BUGS = "ONBOARDING$USE_CASE_FIXING_BUGS", + ONBOARDING$USE_CASE_REFACTORING = "ONBOARDING$USE_CASE_REFACTORING", + ONBOARDING$USE_CASE_AUTOMATING_TASKS = "ONBOARDING$USE_CASE_AUTOMATING_TASKS", + ONBOARDING$USE_CASE_NOT_SURE = "ONBOARDING$USE_CASE_NOT_SURE", + ONBOARDING$ROLE_QUESTION = "ONBOARDING$ROLE_QUESTION", + ONBOARDING$ROLE_SOFTWARE_ENGINEER = "ONBOARDING$ROLE_SOFTWARE_ENGINEER", + ONBOARDING$ROLE_ENGINEERING_MANAGER = "ONBOARDING$ROLE_ENGINEERING_MANAGER", + ONBOARDING$ROLE_CTO_FOUNDER = "ONBOARDING$ROLE_CTO_FOUNDER", + ONBOARDING$ROLE_PRODUCT_OPERATIONS = "ONBOARDING$ROLE_PRODUCT_OPERATIONS", + ONBOARDING$ROLE_STUDENT_HOBBYIST = "ONBOARDING$ROLE_STUDENT_HOBBYIST", + ONBOARDING$ROLE_OTHER = "ONBOARDING$ROLE_OTHER", ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 7455ad2436..e1cbb821c1 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -17807,135 +17807,55 @@ "de": "Mitglieder konnten nicht geladen werden", "uk": "Не вдалося завантажити членів" }, - "ONBOARDING$STEP1_TITLE": { - "en": "What's your role?", - "ja": "あなたの役割は?", - "zh-CN": "您的角色是什么?", - "zh-TW": "您的角色是什麼?", - "ko-KR": "귀하의 역할은 무엇입니까?", - "no": "Hva er din rolle?", - "ar": "ما هو دورك؟", - "de": "Was ist Ihre Rolle?", - "fr": "Quel est votre rôle ?", - "it": "Qual è il tuo ruolo?", - "pt": "Qual é o seu papel?", - "es": "¿Cuál es tu rol?", - "tr": "Rolünüz nedir?", - "uk": "Яка ваша роль?" + "ONBOARDING$ORG_NAME_QUESTION": { + "en": "What's the name of your organization?", + "ja": "あなたの組織の名前は?", + "zh-CN": "您的组织名称是什么?", + "zh-TW": "您的組織名稱是什麼?", + "ko-KR": "귀하의 조직 이름은 무엇입니까?", + "no": "Hva er navnet på organisasjonen din?", + "ar": "ما اسم منظمتك؟", + "de": "Wie lautet der Name Ihrer Organisation?", + "fr": "Quel est le nom de votre organisation ?", + "it": "Qual è il nome della tua organizzazione?", + "pt": "Qual é o nome da sua organização?", + "es": "¿Cuál es el nombre de tu organización?", + "tr": "Organizasyonunuzun adı nedir?", + "uk": "Як називається ваша організація?" }, - "ONBOARDING$STEP1_SUBTITLE": { - "en": "Select the option that best fits you", - "ja": "最も当てはまるオプションを選択してください", - "zh-CN": "选择最适合您的选项", - "zh-TW": "選擇最適合您的選項", - "ko-KR": "가장 적합한 옵션을 선택하세요", - "no": "Velg alternativet som passer deg best", - "ar": "اختر الخيار الأنسب لك", - "de": "Wählen Sie die Option, die am besten zu Ihnen passt", - "fr": "Sélectionnez l'option qui vous convient le mieux", - "it": "Seleziona l'opzione più adatta a te", - "pt": "Selecione a opção que melhor se adapta a você", - "es": "Selecciona la opción que mejor te describa", - "tr": "Size en uygun seçeneği seçin", - "uk": "Виберіть варіант, який найкраще вам підходить" + "ONBOARDING$ORG_NAME_INPUT_NAME": { + "en": "Org name", + "ja": "組織名", + "zh-CN": "组织名称", + "zh-TW": "組織名稱", + "ko-KR": "조직 이름", + "no": "Organisasjonsnavn", + "ar": "اسم المنظمة", + "de": "Organisationsname", + "fr": "Nom de l'organisation", + "it": "Nome dell'organizzazione", + "pt": "Nome da organização", + "es": "Nombre de la organización", + "tr": "Organizasyon adı", + "uk": "Назва організації" }, - "ONBOARDING$SOFTWARE_ENGINEER": { - "en": "Software engineer / developer", - "ja": "ソフトウェアエンジニア / 開発者", - "zh-CN": "软件工程师 / 开发者", - "zh-TW": "軟體工程師 / 開發者", - "ko-KR": "소프트웨어 엔지니어 / 개발자", - "no": "Programvareingeniør / utvikler", - "ar": "مهندس برمجيات / مطور", - "de": "Softwareentwickler / Entwickler", - "fr": "Ingénieur logiciel / développeur", - "it": "Ingegnere software / sviluppatore", - "pt": "Engenheiro de software / desenvolvedor", - "es": "Ingeniero de software / desarrollador", - "tr": "Yazılım mühendisi / geliştirici", - "uk": "Програмний інженер / розробник" + "ONBOARDING$ORG_NAME_INPUT_DOMAIN": { + "en": "Domain name", + "ja": "ドメイン名", + "zh-CN": "域名", + "zh-TW": "網域名稱", + "ko-KR": "도메인 이름", + "no": "Domenenavn", + "ar": "اسم النطاق", + "de": "Domainname", + "fr": "Nom de domaine", + "it": "Nome di dominio", + "pt": "Nome de domínio", + "es": "Nombre de dominio", + "tr": "Alan adı", + "uk": "Доменне ім'я" }, - "ONBOARDING$ENGINEERING_MANAGER": { - "en": "Engineering manager / tech lead", - "ja": "エンジニアリングマネージャー / テックリード", - "zh-CN": "工程经理 / 技术负责人", - "zh-TW": "工程經理 / 技術負責人", - "ko-KR": "엔지니어링 매니저 / 테크 리드", - "no": "Ingeniørsjef / teknisk leder", - "ar": "مدير هندسة / قائد تقني", - "de": "Engineering Manager / Tech Lead", - "fr": "Responsable ingénierie / tech lead", - "it": "Engineering manager / tech lead", - "pt": "Gerente de engenharia / tech lead", - "es": "Gerente de ingeniería / tech lead", - "tr": "Mühendislik müdürü / teknik lider", - "uk": "Менеджер з розробки / технічний лідер" - }, - "ONBOARDING$CTO_FOUNDER": { - "en": "CTO / founder", - "ja": "CTO / 創業者", - "zh-CN": "CTO / 创始人", - "zh-TW": "CTO / 創辦人", - "ko-KR": "CTO / 창업자", - "no": "CTO / grunnlegger", - "ar": "مدير التكنولوجيا / مؤسس", - "de": "CTO / Gründer", - "fr": "CTO / fondateur", - "it": "CTO / fondatore", - "pt": "CTO / fundador", - "es": "CTO / fundador", - "tr": "CTO / kurucu", - "uk": "CTO / засновник" - }, - "ONBOARDING$PRODUCT_OPERATIONS": { - "en": "Product or operations role", - "ja": "プロダクトまたはオペレーションの役割", - "zh-CN": "产品或运营角色", - "zh-TW": "產品或營運角色", - "ko-KR": "제품 또는 운영 역할", - "no": "Produkt- eller driftsrolle", - "ar": "دور المنتج أو العمليات", - "de": "Produkt- oder Betriebsrolle", - "fr": "Rôle produit ou opérations", - "it": "Ruolo prodotto o operazioni", - "pt": "Função de produto ou operações", - "es": "Rol de producto u operaciones", - "tr": "Ürün veya operasyon rolü", - "uk": "Роль продукту або операцій" - }, - "ONBOARDING$STUDENT_HOBBYIST": { - "en": "Student / hobbyist", - "ja": "学生 / 趣味", - "zh-CN": "学生 / 爱好者", - "zh-TW": "學生 / 愛好者", - "ko-KR": "학생 / 취미", - "no": "Student / hobbyist", - "ar": "طالب / هاوٍ", - "de": "Student / Hobbyist", - "fr": "Étudiant / amateur", - "it": "Studente / hobbista", - "pt": "Estudante / hobbyista", - "es": "Estudiante / aficionado", - "tr": "Öğrenci / hobi", - "uk": "Студент / хобіст" - }, - "ONBOARDING$OTHER": { - "en": "Other", - "ja": "その他", - "zh-CN": "其他", - "zh-TW": "其他", - "ko-KR": "기타", - "no": "Annet", - "ar": "أخرى", - "de": "Andere", - "fr": "Autre", - "it": "Altro", - "pt": "Outro", - "es": "Otro", - "tr": "Diğer", - "uk": "Інше" - }, - "ONBOARDING$STEP2_TITLE": { + "ONBOARDING$ORG_SIZE_QUESTION": { "en": "What size organization do you work for?", "ja": "どのくらいの規模の組織で働いていますか?", "zh-CN": "您所在的组织规模是多大?", @@ -17951,7 +17871,23 @@ "tr": "Hangi büyüklükte bir organizasyon için çalışıyorsunuz?", "uk": "Якого розміру організація, в якій ви працюєте?" }, - "ONBOARDING$SOLO": { + "ONBOARDING$ORG_SIZE_SUBTITLE": { + "en": "Select one", + "ja": "1つ選択", + "zh-CN": "选择一个", + "zh-TW": "選擇一個", + "ko-KR": "하나 선택", + "no": "Velg én", + "ar": "اختر واحداً", + "de": "Wählen Sie eine Option", + "fr": "Sélectionnez une option", + "it": "Seleziona una opzione", + "pt": "Selecione uma opção", + "es": "Seleccione una opción", + "tr": "Bir seçenek seçin", + "uk": "Виберіть один" + }, + "ONBOARDING$ORG_SIZE_SOLO": { "en": "Just me (solo)", "ja": "自分だけ(ソロ)", "zh-CN": "只有我(个人)", @@ -17967,7 +17903,7 @@ "tr": "Sadece ben (solo)", "uk": "Тільки я (соло)" }, - "ONBOARDING$ORG_2_10": { + "ONBOARDING$ORG_SIZE_2_10": { "en": "2–10 people", "ja": "2〜10人", "zh-CN": "2-10人", @@ -17983,7 +17919,7 @@ "tr": "2–10 kişi", "uk": "2–10 осіб" }, - "ONBOARDING$ORG_11_50": { + "ONBOARDING$ORG_SIZE_11_50": { "en": "11–50 people", "ja": "11〜50人", "zh-CN": "11-50人", @@ -17999,7 +17935,7 @@ "tr": "11–50 kişi", "uk": "11–50 осіб" }, - "ONBOARDING$ORG_51_200": { + "ONBOARDING$ORG_SIZE_51_200": { "en": "51–200 people", "ja": "51〜200人", "zh-CN": "51-200人", @@ -18015,39 +17951,23 @@ "tr": "51–200 kişi", "uk": "51–200 осіб" }, - "ONBOARDING$ORG_200_1000": { - "en": "200–1000 people", - "ja": "200〜1000人", - "zh-CN": "200-1000人", - "zh-TW": "200-1000人", - "ko-KR": "200-1000명", - "no": "200–1000 personer", - "ar": "200-1000 شخص", - "de": "200–1000 Personen", - "fr": "200–1000 personnes", - "it": "200–1000 persone", - "pt": "200–1000 pessoas", - "es": "200–1000 personas", - "tr": "200–1000 kişi", - "uk": "200–1000 осіб" + "ONBOARDING$ORG_SIZE_200_PLUS": { + "en": "200+ people", + "ja": "200人以上", + "zh-CN": "200+人", + "zh-TW": "200+人", + "ko-KR": "200명 이상", + "no": "200+ personer", + "ar": "أكثر من 200 شخص", + "de": "200+ Personen", + "fr": "200+ personnes", + "it": "200+ persone", + "pt": "200+ pessoas", + "es": "200+ personas", + "tr": "200+ kişi", + "uk": "200+ осіб" }, - "ONBOARDING$ORG_1000_PLUS": { - "en": "1000+ people", - "ja": "1000人以上", - "zh-CN": "1000+人", - "zh-TW": "1000+人", - "ko-KR": "1000명 이상", - "no": "1000+ personer", - "ar": "أكثر من 1000 شخص", - "de": "1000+ Personen", - "fr": "1000+ personnes", - "it": "1000+ persone", - "pt": "1000+ pessoas", - "es": "1000+ personas", - "tr": "1000+ kişi", - "uk": "1000+ осіб" - }, - "ONBOARDING$STEP3_TITLE": { + "ONBOARDING$USE_CASE_QUESTION": { "en": "What use cases are you looking to use OpenHands for?", "ja": "OpenHandsをどのような用途で使用したいですか?", "zh-CN": "您希望将 OpenHands 用于哪些场景?", @@ -18063,7 +17983,23 @@ "tr": "OpenHands'i hangi kullanım alanları için kullanmak istiyorsunuz?", "uk": "Для яких випадків використання ви хочете використовувати OpenHands?" }, - "ONBOARDING$NEW_FEATURES": { + "ONBOARDING$USE_CASE_SUBTITLE": { + "en": "Check all that apply", + "ja": "該当するものをすべて選択", + "zh-CN": "选择所有适用的", + "zh-TW": "選擇所有適用的", + "ko-KR": "해당되는 모든 항목 선택", + "no": "Velg alle som gjelder", + "ar": "اختر كل ما ينطبق", + "de": "Wählen Sie alle zutreffenden", + "fr": "Cochez toutes les options applicables", + "it": "Seleziona tutte le opzioni applicabili", + "pt": "Selecione todas as opções aplicáveis", + "es": "Selecciona todas las que apliquen", + "tr": "Geçerli olanların tümünü seçin", + "uk": "Виберіть усі, що стосуються" + }, + "ONBOARDING$USE_CASE_NEW_FEATURES": { "en": "Writing new features to existing products", "ja": "既存の製品に新機能を追加", "zh-CN": "为现有产品编写新功能", @@ -18079,7 +18015,7 @@ "tr": "Mevcut ürünlere yeni özellikler yazmak", "uk": "Написання нових функцій для існуючих продуктів" }, - "ONBOARDING$APP_FROM_SCRATCH": { + "ONBOARDING$USE_CASE_APP_FROM_SCRATCH": { "en": "Starting an app from scratch", "ja": "ゼロからアプリを開発", "zh-CN": "从头开始创建应用", @@ -18095,7 +18031,7 @@ "tr": "Sıfırdan bir uygulama başlatmak", "uk": "Створення додатку з нуля" }, - "ONBOARDING$FIXING_BUGS": { + "ONBOARDING$USE_CASE_FIXING_BUGS": { "en": "Fixing bugs", "ja": "バグの修正", "zh-CN": "修复漏洞", @@ -18111,7 +18047,7 @@ "tr": "Hataları düzeltmek", "uk": "Виправлення помилок" }, - "ONBOARDING$REFACTORING": { + "ONBOARDING$USE_CASE_REFACTORING": { "en": "Refactoring existing code / eliminating tech debt", "ja": "既存コードのリファクタリング / 技術的負債の解消", "zh-CN": "重构现有代码 / 消除技术债务", @@ -18127,7 +18063,7 @@ "tr": "Mevcut kodu yeniden düzenlemek / teknik borcu ortadan kaldırmak", "uk": "Рефакторинг існуючого коду / усунення технічного боргу" }, - "ONBOARDING$AUTOMATING_TASKS": { + "ONBOARDING$USE_CASE_AUTOMATING_TASKS": { "en": "Automating repetitive coding tasks", "ja": "繰り返しのコーディング作業の自動化", "zh-CN": "自动化重复性编码任务", @@ -18143,7 +18079,7 @@ "tr": "Tekrarlayan kodlama görevlerini otomatikleştirmek", "uk": "Автоматизація повторюваних завдань кодування" }, - "ONBOARDING$NOT_SURE": { + "ONBOARDING$USE_CASE_NOT_SURE": { "en": "Not sure yet", "ja": "まだ決めていない", "zh-CN": "尚未确定", @@ -18159,6 +18095,118 @@ "tr": "Henüz emin değilim", "uk": "Ще не впевнений" }, + "ONBOARDING$ROLE_QUESTION": { + "en": "What's your role?", + "ja": "あなたの役割は?", + "zh-CN": "您的角色是什么?", + "zh-TW": "您的角色是什麼?", + "ko-KR": "귀하의 역할은 무엇입니까?", + "no": "Hva er din rolle?", + "ar": "ما هو دورك؟", + "de": "Was ist Ihre Rolle?", + "fr": "Quel est votre rôle ?", + "it": "Qual è il tuo ruolo?", + "pt": "Qual é o seu papel?", + "es": "¿Cuál es tu rol?", + "tr": "Rolünüz nedir?", + "uk": "Яка ваша роль?" + }, + "ONBOARDING$ROLE_SOFTWARE_ENGINEER": { + "en": "Software engineer / developer", + "ja": "ソフトウェアエンジニア / 開発者", + "zh-CN": "软件工程师 / 开发者", + "zh-TW": "軟體工程師 / 開發者", + "ko-KR": "소프트웨어 엔지니어 / 개발자", + "no": "Programvareingeniør / utvikler", + "ar": "مهندس برمجيات / مطور", + "de": "Softwareentwickler / Entwickler", + "fr": "Ingénieur logiciel / développeur", + "it": "Ingegnere software / sviluppatore", + "pt": "Engenheiro de software / desenvolvedor", + "es": "Ingeniero de software / desarrollador", + "tr": "Yazılım mühendisi / geliştirici", + "uk": "Програмний інженер / розробник" + }, + "ONBOARDING$ROLE_ENGINEERING_MANAGER": { + "en": "Engineering manager / tech lead", + "ja": "エンジニアリングマネージャー / テックリード", + "zh-CN": "工程经理 / 技术负责人", + "zh-TW": "工程經理 / 技術負責人", + "ko-KR": "엔지니어링 매니저 / 테크 리드", + "no": "Ingeniørsjef / teknisk leder", + "ar": "مدير هندسة / قائد تقني", + "de": "Engineering Manager / Tech Lead", + "fr": "Responsable ingénierie / tech lead", + "it": "Engineering manager / tech lead", + "pt": "Gerente de engenharia / tech lead", + "es": "Gerente de ingeniería / tech lead", + "tr": "Mühendislik müdürü / teknik lider", + "uk": "Менеджер з розробки / технічний лідер" + }, + "ONBOARDING$ROLE_CTO_FOUNDER": { + "en": "CTO / founder", + "ja": "CTO / 創業者", + "zh-CN": "CTO / 创始人", + "zh-TW": "CTO / 創辦人", + "ko-KR": "CTO / 창업자", + "no": "CTO / grunnlegger", + "ar": "مدير التكنولوجيا / مؤسس", + "de": "CTO / Gründer", + "fr": "CTO / fondateur", + "it": "CTO / fondatore", + "pt": "CTO / fundador", + "es": "CTO / fundador", + "tr": "CTO / kurucu", + "uk": "CTO / засновник" + }, + "ONBOARDING$ROLE_PRODUCT_OPERATIONS": { + "en": "Product or operations role", + "ja": "プロダクトまたはオペレーションの役割", + "zh-CN": "产品或运营角色", + "zh-TW": "產品或營運角色", + "ko-KR": "제품 또는 운영 역할", + "no": "Produkt- eller driftsrolle", + "ar": "دور المنتج أو العمليات", + "de": "Produkt- oder Betriebsrolle", + "fr": "Rôle produit ou opérations", + "it": "Ruolo prodotto o operazioni", + "pt": "Função de produto ou operações", + "es": "Rol de producto u operaciones", + "tr": "Ürün veya operasyon rolü", + "uk": "Роль продукту або операцій" + }, + "ONBOARDING$ROLE_STUDENT_HOBBYIST": { + "en": "Student / hobbyist", + "ja": "学生 / 趣味", + "zh-CN": "学生 / 爱好者", + "zh-TW": "學生 / 愛好者", + "ko-KR": "학생 / 취미", + "no": "Student / hobbyist", + "ar": "طالب / هاوٍ", + "de": "Student / Hobbyist", + "fr": "Étudiant / amateur", + "it": "Studente / hobbista", + "pt": "Estudante / hobbyista", + "es": "Estudiante / aficionado", + "tr": "Öğrenci / hobi", + "uk": "Студент / хобіст" + }, + "ONBOARDING$ROLE_OTHER": { + "en": "Other", + "ja": "その他", + "zh-CN": "其他", + "zh-TW": "其他", + "ko-KR": "기타", + "no": "Annet", + "ar": "أخرى", + "de": "Andere", + "fr": "Autre", + "it": "Altro", + "pt": "Outro", + "es": "Otro", + "tr": "Diğer", + "uk": "Інше" + }, "ONBOARDING$NEXT_BUTTON": { "en": "Next", "ja": "次へ", diff --git a/frontend/src/routes/onboarding-form.tsx b/frontend/src/routes/onboarding-form.tsx index e019e225fa..6d5c092247 100644 --- a/frontend/src/routes/onboarding-form.tsx +++ b/frontend/src/routes/onboarding-form.tsx @@ -1,8 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, redirect } from "react-router"; -import OptionService from "#/api/option-service/option-service.api"; -import { queryClient } from "#/query-client-config"; import StepHeader from "#/components/features/onboarding/step-header"; import { StepContent } from "#/components/features/onboarding/step-content"; import { BrandButton } from "#/components/features/settings/brand-button"; @@ -13,159 +11,154 @@ import { useTracking } from "#/hooks/use-tracking"; import { ENABLE_ONBOARDING } from "#/utils/feature-flags"; import { cn } from "#/utils/utils"; import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { useConfig } from "#/hooks/query/use-config"; +import { + ONBOARDING_FORM, + OnboardingQuestion, + OnboardingAppMode, +} from "#/constants/onboarding"; export const clientLoader = async () => { - const config = await queryClient.ensureQueryData({ - queryKey: ["config"], - queryFn: OptionService.getConfig, - }); - - if (config.app_mode !== "saas" || !ENABLE_ONBOARDING()) { + if (!ENABLE_ONBOARDING()) { return redirect("/"); } return null; }; -interface StepOption { - id: string; - labelKey?: I18nKey; - label?: string; +type OnboardingAnswers = Record; + +function getOnboardingAppMode(): OnboardingAppMode { + // TODO: query for app mode (saas or self hosted super user) + return "saas"; } -interface FormStep { - id: string; - titleKey: I18nKey; - options: StepOption[]; +function getAnswerAsArray(answers: OnboardingAnswers, key: string): string[] { + const value = answers[key]; + if (!value) return []; + return Array.isArray(value) ? value : [value]; } -const steps: FormStep[] = [ - { - id: "step1", - titleKey: I18nKey.ONBOARDING$STEP1_TITLE, - options: [ - { - id: "software_engineer", - labelKey: I18nKey.ONBOARDING$SOFTWARE_ENGINEER, - }, - { - id: "engineering_manager", - labelKey: I18nKey.ONBOARDING$ENGINEERING_MANAGER, - }, - { - id: "cto_founder", - labelKey: I18nKey.ONBOARDING$CTO_FOUNDER, - }, - { - id: "product_operations", - labelKey: I18nKey.ONBOARDING$PRODUCT_OPERATIONS, - }, - { - id: "student_hobbyist", - labelKey: I18nKey.ONBOARDING$STUDENT_HOBBYIST, - }, - { - id: "other", - labelKey: I18nKey.ONBOARDING$OTHER, - }, - ], - }, - { - id: "step2", - titleKey: I18nKey.ONBOARDING$STEP2_TITLE, - options: [ - { - id: "solo", - labelKey: I18nKey.ONBOARDING$SOLO, - }, - { - id: "org_2_10", - labelKey: I18nKey.ONBOARDING$ORG_2_10, - }, - { - id: "org_11_50", - labelKey: I18nKey.ONBOARDING$ORG_11_50, - }, - { - id: "org_51_200", - labelKey: I18nKey.ONBOARDING$ORG_51_200, - }, - { - id: "org_200_1000", - labelKey: I18nKey.ONBOARDING$ORG_200_1000, - }, - { - id: "org_1000_plus", - labelKey: I18nKey.ONBOARDING$ORG_1000_PLUS, - }, - ], - }, - { - id: "step3", - titleKey: I18nKey.ONBOARDING$STEP3_TITLE, - options: [ - { - id: "new_features", - labelKey: I18nKey.ONBOARDING$NEW_FEATURES, - }, - { - id: "app_from_scratch", - labelKey: I18nKey.ONBOARDING$APP_FROM_SCRATCH, - }, - { - id: "fixing_bugs", - labelKey: I18nKey.ONBOARDING$FIXING_BUGS, - }, - { - id: "refactoring", - labelKey: I18nKey.ONBOARDING$REFACTORING, - }, - { - id: "automating_tasks", - labelKey: I18nKey.ONBOARDING$AUTOMATING_TASKS, - }, - { - id: "not_sure", - labelKey: I18nKey.ONBOARDING$NOT_SURE, - }, - ], - }, -]; +function getTranslatedOptions( + step: OnboardingQuestion, + t: (key: I18nKey) => string, +) { + if (step.type === "input") return undefined; + return step.answerOptions.map((option) => ({ + id: option.id, + label: t(option.key), + })); +} + +function getTranslatedInputFields( + step: OnboardingQuestion, + t: (key: I18nKey) => string, +) { + if (step.type !== "input") return undefined; + return step.inputOptions.map((field) => ({ + id: field.id, + label: t(field.key), + })); +} function OnboardingForm() { const { t } = useTranslation(); const navigate = useNavigate(); + const config = useConfig({ enabled: true }); const { mutate: submitOnboarding } = useSubmitOnboarding(); const { trackOnboardingCompleted } = useTracking(); - const [currentStepIndex, setCurrentStepIndex] = React.useState(0); - const [selections, setSelections] = React.useState>( - {}, + const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode(); + + const steps = React.useMemo( + () => + ONBOARDING_FORM.filter((step) => + step.app_mode.includes(onboardingAppMode), + ), + [onboardingAppMode], ); + const [currentStepIndex, setCurrentStepIndex] = React.useState(0); + const [answers, setAnswers] = React.useState({}); + const currentStep = steps[currentStepIndex]; const isLastStep = currentStepIndex === steps.length - 1; const isFirstStep = currentStepIndex === 0; - const currentSelection = selections[currentStep.id] || null; + + const currentSelections = React.useMemo( + () => (currentStep ? getAnswerAsArray(answers, currentStep.id) : []), + [answers, currentStep], + ); + + const isStepComplete = React.useMemo(() => { + if (!currentStep) return false; + + if (currentStep.type === "input") { + return currentStep.inputOptions.every((field) => { + const value = answers[field.id]; + return typeof value === "string" && value.trim() !== ""; + }); + } + return currentSelections.length > 0; + }, [currentStep, answers, currentSelections]); + + const inputValues = React.useMemo(() => { + const result: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; + }, [answers]); const handleSelectOption = (optionId: string) => { - setSelections((prev) => ({ + if (!currentStep) return; + + if (currentStep.type === "multi") { + setAnswers((prev) => { + const currentArray = getAnswerAsArray(prev, currentStep.id); + + if (currentArray.includes(optionId)) { + return { + ...prev, + [currentStep.id]: currentArray.filter((id) => id !== optionId), + }; + } + return { + ...prev, + [currentStep.id]: [...currentArray, optionId], + }; + }); + } else { + setAnswers((prev) => ({ + ...prev, + [currentStep.id]: optionId, + })); + } + }; + + const handleInputChange = (fieldId: string, value: string) => { + setAnswers((prev) => ({ ...prev, - [currentStep.id]: optionId, + [fieldId]: value, })); }; const handleNext = () => { if (isLastStep) { - submitOnboarding({ selections }); - try { + submitOnboarding({ selections: answers }); + + // Only track onboarding for SaaS users + if (config.data?.app_mode === "saas") { trackOnboardingCompleted({ - role: selections.step1, - orgSize: selections.step2, - useCase: selections.step3, + role: typeof answers.role === "string" ? answers.role : undefined, + orgSize: + typeof answers.org_size === "string" ? answers.org_size : undefined, + useCase: Array.isArray(answers.use_case) + ? answers.use_case + : undefined, }); - } catch (error) { - console.error("Failed to track onboarding:", error); } } else { setCurrentStepIndex((prev) => prev + 1); @@ -180,10 +173,12 @@ function OnboardingForm() { } }; - const translatedOptions = currentStep.options.map((option) => ({ - id: option.id, - label: option.labelKey ? t(option.labelKey) : option.label!, - })); + if (!currentStep) { + return null; + } + + const translatedOptions = getTranslatedOptions(currentStep, t); + const translatedInputFields = getTranslatedInputFields(currentStep, t); return ( @@ -195,14 +190,20 @@ function OnboardingForm() {
{t(