feat(frontend): SaaS NUE profile questions /Onboarding flow (#13029)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
HeyItsChloe
2026-02-27 22:27:22 -08:00
committed by GitHub
parent eec17311c7
commit 1f82ff04d9
21 changed files with 1401 additions and 22 deletions

View File

@@ -0,0 +1,224 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../test-utils";
import OnboardingForm from "#/routes/onboarding-form";
const mockMutate = vi.fn();
const mockNavigate = vi.fn();
vi.mock("react-router", async (importOriginal) => {
const original = await importOriginal<typeof import("react-router")>();
return {
...original,
useNavigate: () => mockNavigate,
};
});
vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
useSubmitOnboarding: () => ({
mutate: mockMutate,
}),
}));
const renderOnboardingForm = () => {
return renderWithProviders(
<MemoryRouter>
<OnboardingForm />
</MemoryRouter>,
);
};
describe("OnboardingForm", () => {
beforeEach(() => {
mockMutate.mockClear();
mockNavigate.mockClear();
});
it("should render with the correct test id", () => {
renderOnboardingForm();
expect(screen.getByTestId("onboarding-form")).toBeInTheDocument();
});
it("should render the first step initially", () => {
renderOnboardingForm();
expect(screen.getByTestId("step-header")).toBeInTheDocument();
expect(screen.getByTestId("step-content")).toBeInTheDocument();
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
});
it("should display step progress indicator with 3 bars", () => {
renderOnboardingForm();
const stepHeader = screen.getByTestId("step-header");
const progressBars = stepHeader.querySelectorAll(".rounded-full");
expect(progressBars).toHaveLength(3);
});
it("should have the Next button disabled when no option is selected", () => {
renderOnboardingForm();
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).toBeDisabled();
});
it("should enable the Next button when an option is selected", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
});
it("should advance to the next step when Next is clicked", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// On step 1, first progress bar should be filled (bg-white)
const stepHeader = screen.getByTestId("step-header");
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 2, first two progress bars should be filled
progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(2);
});
it("should disable Next button again on new step until option is selected", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).toBeDisabled();
});
it("should call submitOnboarding with selections when finishing the last step", async () => {
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
await user.click(screen.getByTestId("step-option-org_2_10"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select use case
await user.click(screen.getByTestId("step-option-new_features"));
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",
},
});
});
it("should render 6 options on step 1", () => {
renderOnboardingForm();
const options = screen
.getAllByRole("button")
.filter((btn) =>
btn.getAttribute("data-testid")?.startsWith("step-option-"),
);
expect(options).toHaveLength(6);
});
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
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select use case on step 3
await user.click(screen.getByTestId("step-option-fixing_bugs"));
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",
},
});
});
it("should show all progress bars filled on the last step", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Navigate to step 3
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 3, all three progress bars should be filled
const stepHeader = screen.getByTestId("step-header");
const progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(3);
});
it("should not render the Back button on the first step", () => {
renderOnboardingForm();
const backButton = screen.queryByRole("button", { name: /back/i });
expect(backButton).not.toBeInTheDocument();
});
it("should render the Back button on step 2", async () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
const backButton = screen.getByRole("button", { name: /back/i });
expect(backButton).toBeInTheDocument();
});
it("should go back to the previous step when Back is clicked", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Navigate to step 2
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Verify we're on step 2 (2 progress bars filled)
const stepHeader = screen.getByTestId("step-header");
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(2);
// Click Back
await user.click(screen.getByRole("button", { name: /back/i }));
// Verify we're back on step 1 (1 progress bar filled)
progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
});
});

View File

@@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepContent } from "#/components/features/onboarding/step-content";
describe("StepContent", () => {
const mockOptions = [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
{ id: "option3", label: "Option 3" },
];
const defaultProps = {
options: mockOptions,
selectedOptionId: null,
onSelectOption: vi.fn(),
};
it("should render with the correct test id", () => {
render(<StepContent {...defaultProps} />);
expect(screen.getByTestId("step-content")).toBeInTheDocument();
});
it("should render all options", () => {
render(<StepContent {...defaultProps} />);
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("Option 3")).toBeInTheDocument();
});
it("should call onSelectOption with correct id when option is clicked", async () => {
const onSelectOptionMock = vi.fn();
const user = userEvent.setup();
render(
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
);
await user.click(screen.getByTestId("step-option-option2"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option2");
});
it("should mark the selected option as selected", () => {
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
const selectedOption = screen.getByTestId("step-option-option1");
const unselectedOption = screen.getByTestId("step-option-option2");
expect(selectedOption).toHaveClass("border-white");
expect(unselectedOption).toHaveClass("border-[#3a3a3a]");
});
it("should render no options when options array is empty", () => {
render(<StepContent {...defaultProps} options={[]} />);
expect(screen.getByTestId("step-content")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should render correct number of options", () => {
render(<StepContent {...defaultProps} />);
const options = screen.getAllByRole("button");
expect(options).toHaveLength(3);
});
it("should allow selecting different options", async () => {
const onSelectOptionMock = vi.fn();
const user = userEvent.setup();
render(
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
);
await user.click(screen.getByTestId("step-option-option1"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option1");
await user.click(screen.getByTestId("step-option-option3"));
expect(onSelectOptionMock).toHaveBeenCalledWith("option3");
expect(onSelectOptionMock).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,75 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import StepHeader from "#/components/features/onboarding/step-header";
describe("StepHeader", () => {
const defaultProps = {
title: "Test Title",
currentStep: 1,
totalSteps: 3,
};
it("should render with the correct test id", () => {
render(<StepHeader {...defaultProps} />);
expect(screen.getByTestId("step-header")).toBeInTheDocument();
});
it("should display the title", () => {
render(<StepHeader {...defaultProps} />);
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("should render correct number of progress dots based on totalSteps", () => {
render(<StepHeader {...defaultProps} totalSteps={5} />);
const stepHeader = screen.getByTestId("step-header");
const progressDots = stepHeader.querySelectorAll(".rounded-full");
expect(progressDots).toHaveLength(5);
});
it("should fill progress dots up to currentStep", () => {
render(<StepHeader {...defaultProps} currentStep={2} totalSteps={4} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(2);
expect(unfilledDots).toHaveLength(2);
});
it("should show all dots filled when on last step", () => {
render(<StepHeader {...defaultProps} currentStep={3} totalSteps={3} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(3);
expect(unfilledDots).toHaveLength(0);
});
it("should show no dots filled when currentStep is 0", () => {
render(<StepHeader {...defaultProps} currentStep={0} totalSteps={3} />);
const stepHeader = screen.getByTestId("step-header");
const filledDots = stepHeader.querySelectorAll(".bg-white");
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
expect(filledDots).toHaveLength(0);
expect(unfilledDots).toHaveLength(3);
});
it("should handle single step progress", () => {
render(<StepHeader {...defaultProps} currentStep={1} totalSteps={1} />);
const stepHeader = screen.getByTestId("step-header");
const progressDots = stepHeader.querySelectorAll(".rounded-full");
const filledDots = stepHeader.querySelectorAll(".bg-white");
expect(progressDots).toHaveLength(1);
expect(filledDots).toHaveLength(1);
});
});

View File

@@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepOption } from "#/components/features/onboarding/step-option";
describe("StepOption", () => {
const defaultProps = {
id: "test-option",
label: "Test Label",
selected: false,
onClick: vi.fn(),
};
it("should render with the correct test id", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByTestId("step-option-test-option")).toBeInTheDocument();
});
it("should display the label", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should call onClick when clicked", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
await user.click(screen.getByTestId("step-option-test-option"));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should call onClick when Enter key is pressed", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
const option = screen.getByTestId("step-option-test-option");
option.focus();
await user.keyboard("{Enter}");
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should call onClick when Space key is pressed", async () => {
const onClickMock = vi.fn();
const user = userEvent.setup();
render(<StepOption {...defaultProps} onClick={onClickMock} />);
const option = screen.getByTestId("step-option-test-option");
option.focus();
await user.keyboard(" ");
expect(onClickMock).toHaveBeenCalledTimes(1);
});
it("should have role='button' for accessibility", () => {
render(<StepOption {...defaultProps} />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should be focusable with tabIndex=0", () => {
render(<StepOption {...defaultProps} />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveAttribute("tabIndex", "0");
});
it("should have selected styling when selected is true", () => {
render(<StepOption {...defaultProps} selected />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveClass("border-white");
});
it("should have unselected styling when selected is false", () => {
render(<StepOption {...defaultProps} selected={false} />);
const option = screen.getByTestId("step-option-test-option");
expect(option).toHaveClass("border-[#3a3a3a]");
});
});

View File

@@ -0,0 +1,64 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Unmock the hook so we can test the real implementation
vi.unmock("#/hooks/use-is-on-intermediate-page");
const useLocationMock = vi.fn();
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useLocation: useLocationMock,
};
});
// Import after mock setup
const { useIsOnIntermediatePage } = await import(
"#/hooks/use-is-on-intermediate-page"
);
describe("useIsOnIntermediatePage", () => {
describe("returns true for intermediate pages", () => {
it("should return true when on /accept-tos page", () => {
useLocationMock.mockReturnValue({ pathname: "/accept-tos" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
it("should return true when on /onboarding page", () => {
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
});
describe("returns false for non-intermediate pages", () => {
it("should return false when on root page", () => {
useLocationMock.mockReturnValue({ pathname: "/" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
it("should return false when on /settings page", () => {
useLocationMock.mockReturnValue({ pathname: "/settings" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
});
describe("handles edge cases", () => {
it("should return false for paths containing intermediate page names", () => {
useLocationMock.mockReturnValue({ pathname: "/accept-tos-extra" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
it("should return false for paths with intermediate page names as subpaths", () => {
useLocationMock.mockReturnValue({ pathname: "/settings/accept-tos" });
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(false);
});
});
});

View File

@@ -0,0 +1,35 @@
import { StepOption } from "./step-option";
export interface Option {
id: string;
label: string;
}
interface StepContentProps {
options: Option[];
selectedOptionId: string | null;
onSelectOption: (optionId: string) => void;
}
export function StepContent({
options,
selectedOptionId,
onSelectOption,
}: StepContentProps) {
return (
<div
data-testid="step-content"
className="flex flex-col mt-8 mb-8 gap-[12px] w-full"
>
{options.map((option) => (
<StepOption
key={option.id}
id={option.id}
label={option.label}
selected={selectedOptionId === option.id}
onClick={() => onSelectOption(option.id)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
interface StepHeaderProps {
title: string;
currentStep: number;
totalSteps: number;
}
function StepHeader({ title, 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">
{Array.from({ length: totalSteps }).map((_, index) => (
<div
key={index}
className={cn(
"w-[6px] h-[4px] rounded-full transition-colors",
index < currentStep ? "bg-white" : "bg-neutral-600",
)}
/>
))}
</div>
<Typography.Text className="text-2xl font-semibold text-content text-center">
{title}
</Typography.Text>
</div>
);
}
export default StepHeader;

View File

@@ -0,0 +1,30 @@
import { cn } from "#/utils/utils";
import { Typography } from "#/ui/typography";
interface StepOptionProps {
id: string;
label: string;
selected: boolean;
onClick: () => void;
}
export function StepOption({ id, label, selected, onClick }: StepOptionProps) {
return (
<button
data-testid={`step-option-${id}`}
type="button"
tabIndex={0}
onClick={onClick}
className={cn(
"min-h-10 w-full rounded-md border text-left px-4 py-2.5 transition-colors text-white cursor-pointer",
selected
? "border-white bg-[#3a3a3a]"
: "border-[#3a3a3a] hover:bg-[#3a3a3a]",
)}
>
<Typography.Text className="text-sm font-medium text-content">
{label}
</Typography.Text>
</button>
);
}

View File

@@ -0,0 +1,36 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SubmitOnboardingArgs = {
selections: Record<string, string>;
};
export const useSubmitOnboarding = () => {
const navigate = useNavigate();
return useMutation({
mutationFn: async ({ selections }: SubmitOnboardingArgs) =>
// TODO: mark onboarding as complete
// TODO: persist user responses
({ selections }),
onSuccess: () => {
const finalRedirectUrl = "/"; // TODO: use redirect url from api response
// Check if the redirect URL is an external URL (starts with http or https)
if (
finalRedirectUrl.startsWith("http://") ||
finalRedirectUrl.startsWith("https://")
) {
// For external URLs, redirect using window.location
window.location.href = finalRedirectUrl;
} else {
// For internal routes, use navigate
navigate(finalRedirectUrl);
}
},
onError: (error) => {
displayErrorToast(error.message);
window.location.href = "/";
},
});
};

View File

@@ -1,15 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import OptionService from "#/api/option-service/option-service.api";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
export const useConfig = () => {
const isOnTosPage = useIsOnTosPage();
const isOnIntermediatePage = useIsOnIntermediatePage();
return useQuery({
queryKey: ["web-client-config"],
queryFn: OptionService.getConfig,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes,
enabled: !isOnTosPage,
enabled: !isOnIntermediatePage,
});
};

View File

@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
import AuthService from "#/api/auth-service/auth-service.api";
import { useConfig } from "./use-config";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
export const useIsAuthed = () => {
const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage();
const isOnIntermediatePage = useIsOnIntermediatePage();
const appMode = config?.app_mode;
@@ -29,7 +29,7 @@ export const useIsAuthed = () => {
throw error;
}
},
enabled: !!appMode && !isOnTosPage,
enabled: !!appMode && !isOnIntermediatePage,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: false,

View File

@@ -1,7 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
@@ -22,7 +22,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
};
export const useSettings = () => {
const isOnTosPage = useIsOnTosPage();
const isOnIntermediatePage = useIsOnIntermediatePage();
const { data: userIsAuthenticated } = useIsAuthed();
const query = useQuery({
@@ -35,7 +35,7 @@ export const useSettings = () => {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage && !!userIsAuthenticated,
enabled: !isOnIntermediatePage && !!userIsAuthenticated,
meta: {
disableToast: true,
},

View File

@@ -0,0 +1,17 @@
import { useLocation } from "react-router";
const INTERMEDIATE_PAGE_PATHS = ["/accept-tos", "/onboarding"];
/**
* Checks if the current page is an intermediate page.
*
* This hook is reusable for all intermediate pages. To add a new intermediate page,
* add its path to INTERMEDIATE_PAGE_PATHS array.
*/
export const useIsOnIntermediatePage = (): boolean => {
const { pathname } = useLocation();
return INTERMEDIATE_PAGE_PATHS.includes(
pathname as (typeof INTERMEDIATE_PAGE_PATHS)[number],
);
};

View File

@@ -105,6 +105,23 @@ export const useTracking = () => {
});
};
const trackOnboardingCompleted = ({
role,
orgSize,
useCase,
}: {
role: string;
orgSize: string;
useCase: string;
}) => {
posthog.capture("onboarding_completed", {
role,
org_size: orgSize,
use_case: useCase,
...commonProperties,
});
};
return {
trackLoginButtonClick,
trackConversationCreated,
@@ -116,5 +133,6 @@ export const useTracking = () => {
trackCreditsPurchased,
trackCreditLimitReached,
trackAddTeamMembersButtonClick,
trackOnboardingCompleted,
};
};

View File

@@ -1013,4 +1013,29 @@ export enum I18nKey {
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
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$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
}

View File

@@ -16206,5 +16206,405 @@
"tr": "Bağlantı panoya kopyalandı",
"de": "Link in die Zwischenablage kopiert",
"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$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$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$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": {
"en": "What size organization do you work for?",
"ja": "どのくらいの規模の組織で働いていますか?",
"zh-CN": "您所在的组织规模是多大?",
"zh-TW": "您所在的組織規模是多大?",
"ko-KR": "어느 규모의 조직에서 일하고 계십니까?",
"no": "Hvor stor organisasjon jobber du for?",
"ar": "ما حجم المنظمة التي تعمل بها؟",
"de": "Für welche Unternehmensgröße arbeiten Sie?",
"fr": "Quelle est la taille de votre organisation ?",
"it": "Per quale dimensione di organizzazione lavori?",
"pt": "Qual o tamanho da organização em que você trabalha?",
"es": "¿De qué tamaño es la organización para la que trabajas?",
"tr": "Hangi büyüklükte bir organizasyon için çalışıyorsunuz?",
"uk": "Якого розміру організація, в якій ви працюєте?"
},
"ONBOARDING$SOLO": {
"en": "Just me (solo)",
"ja": "自分だけ(ソロ)",
"zh-CN": "只有我(个人)",
"zh-TW": "只有我(個人)",
"ko-KR": "저만 (개인)",
"no": "Bare meg (solo)",
"ar": "أنا فقط (منفرد)",
"de": "Nur ich (solo)",
"fr": "Juste moi (solo)",
"it": "Solo io (individuale)",
"pt": "Apenas eu (solo)",
"es": "Solo yo (individual)",
"tr": "Sadece ben (solo)",
"uk": "Тільки я (соло)"
},
"ONBOARDING$ORG_2_10": {
"en": "210 people",
"ja": "2〜10人",
"zh-CN": "2-10人",
"zh-TW": "2-10人",
"ko-KR": "2-10명",
"no": "210 personer",
"ar": "2-10 أشخاص",
"de": "210 Personen",
"fr": "210 personnes",
"it": "210 persone",
"pt": "210 pessoas",
"es": "210 personas",
"tr": "210 kişi",
"uk": "210 осіб"
},
"ONBOARDING$ORG_11_50": {
"en": "1150 people",
"ja": "11〜50人",
"zh-CN": "11-50人",
"zh-TW": "11-50人",
"ko-KR": "11-50명",
"no": "1150 personer",
"ar": "11-50 شخصاً",
"de": "1150 Personen",
"fr": "1150 personnes",
"it": "1150 persone",
"pt": "1150 pessoas",
"es": "1150 personas",
"tr": "1150 kişi",
"uk": "1150 осіб"
},
"ONBOARDING$ORG_51_200": {
"en": "51200 people",
"ja": "51〜200人",
"zh-CN": "51-200人",
"zh-TW": "51-200人",
"ko-KR": "51-200명",
"no": "51200 personer",
"ar": "51-200 شخصاً",
"de": "51200 Personen",
"fr": "51200 personnes",
"it": "51200 persone",
"pt": "51200 pessoas",
"es": "51200 personas",
"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_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": {
"en": "What use cases are you looking to use OpenHands for?",
"ja": "OpenHandsをどのような用途で使用したいですか",
"zh-CN": "您希望将 OpenHands 用于哪些场景?",
"zh-TW": "您希望將 OpenHands 用於哪些場景?",
"ko-KR": "OpenHands를 어떤 용도로 사용하시겠습니까?",
"no": "Hvilke bruksområder ønsker du å bruke OpenHands til?",
"ar": "ما هي حالات الاستخدام التي تريد استخدام OpenHands لها؟",
"de": "Für welche Anwendungsfälle möchten Sie OpenHands nutzen?",
"fr": "Pour quels cas d'utilisation souhaitez-vous utiliser OpenHands ?",
"it": "Per quali casi d'uso vorresti usare OpenHands?",
"pt": "Para quais casos de uso você pretende usar o OpenHands?",
"es": "¿Para qué casos de uso quieres usar OpenHands?",
"tr": "OpenHands'i hangi kullanım alanları için kullanmak istiyorsunuz?",
"uk": "Для яких випадків використання ви хочете використовувати OpenHands?"
},
"ONBOARDING$NEW_FEATURES": {
"en": "Writing new features to existing products",
"ja": "既存の製品に新機能を追加",
"zh-CN": "为现有产品编写新功能",
"zh-TW": "為現有產品編寫新功能",
"ko-KR": "기존 제품에 새로운 기능 작성",
"no": "Skrive nye funksjoner til eksisterende produkter",
"ar": "كتابة ميزات جديدة للمنتجات الحالية",
"de": "Neue Funktionen für bestehende Produkte schreiben",
"fr": "Écrire de nouvelles fonctionnalités pour des produits existants",
"it": "Scrivere nuove funzionalità per prodotti esistenti",
"pt": "Escrever novos recursos para produtos existentes",
"es": "Escribir nuevas funcionalidades para productos existentes",
"tr": "Mevcut ürünlere yeni özellikler yazmak",
"uk": "Написання нових функцій для існуючих продуктів"
},
"ONBOARDING$APP_FROM_SCRATCH": {
"en": "Starting an app from scratch",
"ja": "ゼロからアプリを開発",
"zh-CN": "从头开始创建应用",
"zh-TW": "從頭開始創建應用",
"ko-KR": "처음부터 앱 시작",
"no": "Starte en app fra bunnen av",
"ar": "بدء تطبيق من الصفر",
"de": "Eine App von Grund auf erstellen",
"fr": "Démarrer une application à partir de zéro",
"it": "Iniziare un'app da zero",
"pt": "Iniciar um aplicativo do zero",
"es": "Comenzar una aplicación desde cero",
"tr": "Sıfırdan bir uygulama başlatmak",
"uk": "Створення додатку з нуля"
},
"ONBOARDING$FIXING_BUGS": {
"en": "Fixing bugs",
"ja": "バグの修正",
"zh-CN": "修复漏洞",
"zh-TW": "修復漏洞",
"ko-KR": "버그 수정",
"no": "Fikse feil",
"ar": "إصلاح الأخطاء",
"de": "Fehler beheben",
"fr": "Corriger des bugs",
"it": "Correggere bug",
"pt": "Corrigir bugs",
"es": "Corregir errores",
"tr": "Hataları düzeltmek",
"uk": "Виправлення помилок"
},
"ONBOARDING$REFACTORING": {
"en": "Refactoring existing code / eliminating tech debt",
"ja": "既存コードのリファクタリング / 技術的負債の解消",
"zh-CN": "重构现有代码 / 消除技术债务",
"zh-TW": "重構現有代碼 / 消除技術債務",
"ko-KR": "기존 코드 리팩토링 / 기술 부채 제거",
"no": "Refaktorere eksisterende kode / eliminere teknisk gjeld",
"ar": "إعادة هيكلة الكود الحالي / إزالة الديون التقنية",
"de": "Bestehenden Code refaktorisieren / technische Schulden abbauen",
"fr": "Refactoriser le code existant / éliminer la dette technique",
"it": "Refactoring del codice esistente / eliminare il debito tecnico",
"pt": "Refatorar código existente / eliminar dívida técnica",
"es": "Refactorizar código existente / eliminar deuda técnica",
"tr": "Mevcut kodu yeniden düzenlemek / teknik borcu ortadan kaldırmak",
"uk": "Рефакторинг існуючого коду / усунення технічного боргу"
},
"ONBOARDING$AUTOMATING_TASKS": {
"en": "Automating repetitive coding tasks",
"ja": "繰り返しのコーディング作業の自動化",
"zh-CN": "自动化重复性编码任务",
"zh-TW": "自動化重複性編碼任務",
"ko-KR": "반복적인 코딩 작업 자동화",
"no": "Automatisere repetitive kodeoppgaver",
"ar": "أتمتة مهام البرمجة المتكررة",
"de": "Wiederkehrende Codierungsaufgaben automatisieren",
"fr": "Automatiser les tâches de codage répétitives",
"it": "Automatizzare attività di codifica ripetitive",
"pt": "Automatizar tarefas de codificação repetitivas",
"es": "Automatizar tareas de codificación repetitivas",
"tr": "Tekrarlayan kodlama görevlerini otomatikleştirmek",
"uk": "Автоматизація повторюваних завдань кодування"
},
"ONBOARDING$NOT_SURE": {
"en": "Not sure yet",
"ja": "まだ決めていない",
"zh-CN": "尚未确定",
"zh-TW": "尚未確定",
"ko-KR": "아직 모르겠습니다",
"no": "Ikke sikker ennå",
"ar": "لست متأكداً بعد",
"de": "Noch nicht sicher",
"fr": "Pas encore sûr",
"it": "Non ancora sicuro",
"pt": "Ainda não tenho certeza",
"es": "Aún no estoy seguro",
"tr": "Henüz emin değilim",
"uk": "Ще не впевнений"
},
"ONBOARDING$NEXT_BUTTON": {
"en": "Next",
"ja": "次へ",
"zh-CN": "下一步",
"zh-TW": "下一步",
"ko-KR": "다음",
"no": "Neste",
"ar": "التالي",
"de": "Weiter",
"fr": "Suivant",
"it": "Avanti",
"pt": "Próximo",
"es": "Siguiente",
"tr": "İleri",
"uk": "Далі"
},
"ONBOARDING$BACK_BUTTON": {
"en": "Back",
"ja": "戻る",
"zh-CN": "返回",
"zh-TW": "返回",
"ko-KR": "뒤로",
"no": "Tilbake",
"ar": "رجوع",
"de": "Zurück",
"fr": "Retour",
"it": "Indietro",
"pt": "Voltar",
"es": "Atrás",
"tr": "Geri",
"uk": "Назад"
},
"ONBOARDING$FINISH_BUTTON": {
"en": "Finish",
"ja": "完了",
"zh-CN": "完成",
"zh-TW": "完成",
"ko-KR": "완료",
"no": "Fullfør",
"ar": "إنهاء",
"de": "Fertig",
"fr": "Terminer",
"it": "Fine",
"pt": "Concluir",
"es": "Finalizar",
"tr": "Bitir",
"uk": "Завершити"
}
}

View File

@@ -7,6 +7,7 @@ import {
export default [
route("login", "routes/login.tsx"),
route("onboarding", "routes/onboarding-form.tsx"),
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),

View File

@@ -0,0 +1,243 @@
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";
import { I18nKey } from "#/i18n/declaration";
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
import { useSubmitOnboarding } from "#/hooks/mutation/use-submit-onboarding";
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";
export const clientLoader = async () => {
const config = await queryClient.ensureQueryData({
queryKey: ["config"],
queryFn: OptionService.getConfig,
});
if (config.app_mode !== "saas" || !ENABLE_ONBOARDING()) {
return redirect("/");
}
return null;
};
interface StepOption {
id: string;
labelKey?: I18nKey;
label?: string;
}
interface FormStep {
id: string;
titleKey: I18nKey;
options: StepOption[];
}
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 OnboardingForm() {
const { t } = useTranslation();
const navigate = useNavigate();
const { mutate: submitOnboarding } = useSubmitOnboarding();
const { trackOnboardingCompleted } = useTracking();
const [currentStepIndex, setCurrentStepIndex] = React.useState(0);
const [selections, setSelections] = React.useState<Record<string, string>>(
{},
);
const currentStep = steps[currentStepIndex];
const isLastStep = currentStepIndex === steps.length - 1;
const isFirstStep = currentStepIndex === 0;
const currentSelection = selections[currentStep.id] || null;
const handleSelectOption = (optionId: string) => {
setSelections((prev) => ({
...prev,
[currentStep.id]: optionId,
}));
};
const handleNext = () => {
if (isLastStep) {
submitOnboarding({ selections });
try {
trackOnboardingCompleted({
role: selections.step1,
orgSize: selections.step2,
useCase: selections.step3,
});
} catch (error) {
console.error("Failed to track onboarding:", error);
}
} else {
setCurrentStepIndex((prev) => prev + 1);
}
};
const handleBack = () => {
if (isFirstStep) {
navigate(-1);
} else {
setCurrentStepIndex((prev) => prev - 1);
}
};
const translatedOptions = currentStep.options.map((option) => ({
id: option.id,
label: option.labelKey ? t(option.labelKey) : option.label!,
}));
return (
<ModalBackdrop>
<div
data-testid="onboarding-form"
className="w-[500px] max-w-[calc(100vw-2rem)] mx-auto p-4 sm:p-6 flex flex-col justify-center overflow-hidden"
>
<div className="flex flex-col items-center mb-4">
<OpenHandsLogoWhite width={55} height={55} />
</div>
<StepHeader
title={t(currentStep.titleKey)}
currentStep={currentStepIndex + 1}
totalSteps={steps.length}
/>
<StepContent
options={translatedOptions}
selectedOptionId={currentSelection}
onSelectOption={handleSelectOption}
/>
<div
data-testid="step-actions"
className="flex justify-end items-center gap-3"
>
{!isFirstStep && (
<BrandButton
type="button"
variant="secondary"
onClick={handleBack}
className="flex-1 px-4 sm:px-6 py-2.5 bg-[050505] text-white border hover:bg-white border-[#242424] hover:text-black"
>
{t(I18nKey.ONBOARDING$BACK_BUTTON)}
</BrandButton>
)}
<BrandButton
type="button"
variant="primary"
onClick={handleNext}
isDisabled={!currentSelection}
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
)}
>
{t(
isLastStep
? I18nKey.ONBOARDING$FINISH_BUTTON
: I18nKey.ONBOARDING$NEXT_BUTTON,
)}
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}
export default OnboardingForm;

View File

@@ -19,7 +19,7 @@ import { useSettings } from "#/hooks/query/use-settings";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { useReoTracking } from "#/hooks/use-reo-tracking";
@@ -69,7 +69,7 @@ export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const isOnTosPage = useIsOnTosPage();
const isOnIntermediatePage = useIsOnIntermediatePage();
const { data: settings } = useSettings();
const { migrateUserConsent } = useMigrateUserConsent();
const { t } = useTranslation();
@@ -97,25 +97,25 @@ export default function MainApp() {
useSyncPostHogConsent();
React.useEffect(() => {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.language) {
// Don't change language when on intermediate pages (TOS, profile questions)
if (!isOnIntermediatePage && settings?.language) {
i18n.changeLanguage(settings.language);
}
}, [settings?.language, isOnTosPage]);
}, [settings?.language, isOnIntermediatePage]);
React.useEffect(() => {
// Don't show consent form when on TOS page
if (!isOnTosPage) {
// Don't show consent form when on intermediate pages
if (!isOnIntermediatePage) {
const consentFormModalIsOpen =
settings?.user_consents_to_analytics === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}
}, [settings, isOnTosPage]);
}, [settings, isOnIntermediatePage]);
React.useEffect(() => {
// Don't migrate user consent when on TOS page
if (!isOnTosPage) {
// Don't migrate user consent when on intermediate pages
if (!isOnIntermediatePage) {
// Migrate user consent to the server if it was previously stored in localStorage
migrateUserConsent({
handleAnalyticsWasPresentInLocalStorage: () => {
@@ -123,7 +123,7 @@ export default function MainApp() {
},
});
}
}, [isOnTosPage]);
}, [isOnIntermediatePage]);
React.useEffect(() => {
if (settings?.is_new_user && config.data?.app_mode === "saas") {
@@ -178,7 +178,7 @@ export default function MainApp() {
isAuthLoading ||
(!isAuthed &&
!isAuthError &&
!isOnTosPage &&
!isOnIntermediatePage &&
config.data?.app_mode === "saas" &&
!loginMethodExists);
@@ -209,7 +209,7 @@ export default function MainApp() {
!isAuthed &&
!isAuthError &&
!isFetchingAuth &&
!isOnTosPage &&
!isOnIntermediatePage &&
config.data?.app_mode === "saas" &&
loginMethodExists;

View File

@@ -17,3 +17,4 @@ export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");

View File

@@ -30,6 +30,10 @@ vi.mock("#/hooks/use-is-on-tos-page", () => ({
useIsOnTosPage: () => false,
}));
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
useIsOnIntermediatePage: () => false,
}));
// Import the Zustand mock to enable automatic store resets
vi.mock("zustand");