feat(frontend): self hosted new user questions (#13367)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
HeyItsChloe
2026-03-17 12:51:40 -07:00
committed by GitHub
parent 855ef7ba5f
commit 7516b53f5a
13 changed files with 744 additions and 362 deletions

View File

@@ -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<typeof import("react-router")>();
@@ -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(
<MemoryRouter>
@@ -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)

View File

@@ -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(<StepContent {...defaultProps} selectedOptionId="option1" />);
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
const selectedOption = screen.getByTestId("step-option-option1");
const unselectedOption = screen.getByTestId("step-option-option2");

View File

@@ -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(<StepInput {...defaultProps} />);
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<StepInput {...defaultProps} value="Hello World" />);
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(<StepInput {...defaultProps} onChange={mockOnChange} />);
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(<StepInput {...defaultProps} onChange={mockOnChange} />);
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(<StepInput {...defaultProps} id="org_name" />);
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
});
it("should render as a text input", () => {
render(<StepInput {...defaultProps} />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveAttribute("type", "text");
});
});

View File

@@ -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<string, string>;
onSelectOption: (optionId: string) => void;
onInputChange?: (fieldId: string, value: string) => void;
}
export function StepContent({
options,
selectedOptionId,
inputFields,
selectedOptionIds,
inputValues = {},
onSelectOption,
onInputChange,
}: StepContentProps) {
return (
<div
data-testid="step-content"
className="flex flex-col mt-8 mb-8 gap-[12px] w-full"
>
{options.map((option) => (
{options?.map((option) => (
<StepOption
key={option.id}
id={option.id}
label={option.label}
selected={selectedOptionId === option.id}
selected={selectedOptionIds.includes(option.id)}
onClick={() => onSelectOption(option.id)}
/>
))}
{inputFields?.map((field) => (
<StepInput
key={field.id}
id={field.id}
label={field.label}
value={inputValues[field.id] || ""}
onChange={(value) => onInputChange?.(field.id, value)}
/>
))}
</div>
);
}

View File

@@ -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 (
<div data-testid="step-header" className="flex flex-col items-center gap-2">
<div className="flex justify-center gap-2 mb-2">
@@ -24,6 +30,11 @@ function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) {
<Typography.Text className="text-2xl font-semibold text-content text-center">
{title}
</Typography.Text>
{subtitle && (
<Typography.Text className="text-sm text-neutral-400 text-center">
{subtitle}
</Typography.Text>
)}
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-1.5 w-full">
<label
htmlFor={`step-input-${id}`}
className="text-sm font-medium text-neutral-400 cursor-pointer"
>
{label}
</label>
<input
id={`step-input-${id}`}
data-testid={`step-input-${id}`}
type="text"
value={value}
onChange={(e) => 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"
/>
</div>
);
}

View File

@@ -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" },
],
},
];

View File

@@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitOnboardingArgs = {
selections: Record<string, string>;
selections: Record<string, string | string[]>;
};
export const useSubmitOnboarding = () => {

View File

@@ -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,
});
};

View File

@@ -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,

View File

@@ -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",

View File

@@ -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": "210 people",
"ja": "2〜10人",
"zh-CN": "2-10人",
@@ -17983,7 +17919,7 @@
"tr": "210 kişi",
"uk": "210 осіб"
},
"ONBOARDING$ORG_11_50": {
"ONBOARDING$ORG_SIZE_11_50": {
"en": "1150 people",
"ja": "11〜50人",
"zh-CN": "11-50人",
@@ -17999,7 +17935,7 @@
"tr": "1150 kişi",
"uk": "1150 осіб"
},
"ONBOARDING$ORG_51_200": {
"ONBOARDING$ORG_SIZE_51_200": {
"en": "51200 people",
"ja": "51〜200人",
"zh-CN": "51-200人",
@@ -18015,39 +17951,23 @@
"tr": "51200 kişi",
"uk": "51200 осіб"
},
"ONBOARDING$ORG_200_1000": {
"en": "2001000 people",
"ja": "200〜1000人",
"zh-CN": "200-1000人",
"zh-TW": "200-1000人",
"ko-KR": "200-1000명",
"no": "2001000 personer",
"ar": "200-1000 شخص",
"de": "2001000 Personen",
"fr": "2001000 personnes",
"it": "2001000 persone",
"pt": "2001000 pessoas",
"es": "2001000 personas",
"tr": "2001000 kişi",
"uk": "2001000 осіб"
"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": "次へ",

View File

@@ -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<string, string | string[]>;
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<Record<string, string>>(
{},
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<OnboardingAnswers>({});
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<string, string> = {};
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 (
<ModalBackdrop>
@@ -195,14 +190,20 @@ function OnboardingForm() {
<OpenHandsLogoWhite width={55} height={55} />
</div>
<StepHeader
title={t(currentStep.titleKey)}
title={t(currentStep.questionKey)}
subtitle={
currentStep.subtitleKey ? t(currentStep.subtitleKey) : undefined
}
currentStep={currentStepIndex + 1}
totalSteps={steps.length}
/>
<StepContent
options={translatedOptions}
selectedOptionId={currentSelection}
inputFields={translatedInputFields}
selectedOptionIds={currentSelections}
inputValues={inputValues}
onSelectOption={handleSelectOption}
onInputChange={handleInputChange}
/>
<div
data-testid="step-actions"
@@ -222,10 +223,10 @@ function OnboardingForm() {
type="button"
variant="primary"
onClick={handleNext}
isDisabled={!currentSelection}
isDisabled={!isStepComplete}
className={cn(
"px-4 sm:px-6 py-2.5 bg-white text-black hover:bg-white/90",
isFirstStep ? "w-1/2" : "flex-1", // keep "Next" button to the right. Even if "Back" button is not rendered
isFirstStep ? "w-1/2" : "flex-1",
)}
>
{t(