mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
27
frontend/src/components/features/onboarding/step-input.tsx
Normal file
27
frontend/src/components/features/onboarding/step-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
frontend/src/constants/onboarding.tsx
Normal file
101
frontend/src/constants/onboarding.tsx
Normal 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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "次へ",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user