diff --git a/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx new file mode 100644 index 0000000000..f4735aedc3 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/onboarding-form.test.tsx @@ -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(); + return { + ...original, + useNavigate: () => mockNavigate, + }; +}); + +vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({ + useSubmitOnboarding: () => ({ + mutate: mockMutate, + }), +})); + +const renderOnboardingForm = () => { + return renderWithProviders( + + + , + ); +}; + +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); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/step-content.test.tsx b/frontend/__tests__/components/features/onboarding/step-content.test.tsx new file mode 100644 index 0000000000..e8ac37eda4 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/step-content.test.tsx @@ -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(); + + expect(screen.getByTestId("step-content")).toBeInTheDocument(); + }); + + it("should render all options", () => { + render(); + + 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( + , + ); + + await user.click(screen.getByTestId("step-option-option2")); + + expect(onSelectOptionMock).toHaveBeenCalledWith("option2"); + }); + + it("should mark the selected option as selected", () => { + render(); + + 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(); + + expect(screen.getByTestId("step-content")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("should render correct number of options", () => { + render(); + + 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( + , + ); + + 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); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/step-header.test.tsx b/frontend/__tests__/components/features/onboarding/step-header.test.tsx new file mode 100644 index 0000000000..48cea1bec9 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/step-header.test.tsx @@ -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(); + + expect(screen.getByTestId("step-header")).toBeInTheDocument(); + }); + + it("should display the title", () => { + render(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + }); + + it("should render correct number of progress dots based on totalSteps", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/step-option.test.tsx b/frontend/__tests__/components/features/onboarding/step-option.test.tsx new file mode 100644 index 0000000000..950a5ff258 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/step-option.test.tsx @@ -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(); + + expect(screen.getByTestId("step-option-test-option")).toBeInTheDocument(); + }); + + it("should display the label", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const onClickMock = vi.fn(); + const user = userEvent.setup(); + + render(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByRole("button")).toBeInTheDocument(); + }); + + it("should be focusable with tabIndex=0", () => { + render(); + + const option = screen.getByTestId("step-option-test-option"); + expect(option).toHaveAttribute("tabIndex", "0"); + }); + + it("should have selected styling when selected is true", () => { + render(); + + const option = screen.getByTestId("step-option-test-option"); + expect(option).toHaveClass("border-white"); + }); + + it("should have unselected styling when selected is false", () => { + render(); + + const option = screen.getByTestId("step-option-test-option"); + expect(option).toHaveClass("border-[#3a3a3a]"); + }); +}); diff --git a/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts new file mode 100644 index 0000000000..2879815745 --- /dev/null +++ b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/src/components/features/onboarding/step-content.tsx b/frontend/src/components/features/onboarding/step-content.tsx new file mode 100644 index 0000000000..658b8f63c0 --- /dev/null +++ b/frontend/src/components/features/onboarding/step-content.tsx @@ -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 ( +
+ {options.map((option) => ( + onSelectOption(option.id)} + /> + ))} +
+ ); +} diff --git a/frontend/src/components/features/onboarding/step-header.tsx b/frontend/src/components/features/onboarding/step-header.tsx new file mode 100644 index 0000000000..bf1e458ec7 --- /dev/null +++ b/frontend/src/components/features/onboarding/step-header.tsx @@ -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 ( +
+
+ {Array.from({ length: totalSteps }).map((_, index) => ( +
+ ))} +
+ + {title} + +
+ ); +} + +export default StepHeader; diff --git a/frontend/src/components/features/onboarding/step-option.tsx b/frontend/src/components/features/onboarding/step-option.tsx new file mode 100644 index 0000000000..3b5648b5c6 --- /dev/null +++ b/frontend/src/components/features/onboarding/step-option.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/hooks/mutation/use-submit-onboarding.ts b/frontend/src/hooks/mutation/use-submit-onboarding.ts new file mode 100644 index 0000000000..63fbebf986 --- /dev/null +++ b/frontend/src/hooks/mutation/use-submit-onboarding.ts @@ -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; +}; + +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 = "/"; + }, + }); +}; diff --git a/frontend/src/hooks/query/use-config.ts b/frontend/src/hooks/query/use-config.ts index 8a97e20c62..a168be984b 100644 --- a/frontend/src/hooks/query/use-config.ts +++ b/frontend/src/hooks/query/use-config.ts @@ -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, }); }; diff --git a/frontend/src/hooks/query/use-is-authed.ts b/frontend/src/hooks/query/use-is-authed.ts index 5b969629f7..81a38a7c1c 100644 --- a/frontend/src/hooks/query/use-is-authed.ts +++ b/frontend/src/hooks/query/use-is-authed.ts @@ -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, diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index faf34d5dae..ce01e4f69b 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -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 => { }; 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, }, diff --git a/frontend/src/hooks/use-is-on-intermediate-page.ts b/frontend/src/hooks/use-is-on-intermediate-page.ts new file mode 100644 index 0000000000..955fdc28ee --- /dev/null +++ b/frontend/src/hooks/use-is-on-intermediate-page.ts @@ -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], + ); +}; diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 74c533a2d8..fea2f29d86 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -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, }; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 51bbd1ac0d..b0253a5b6e 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 98a1c0c580..a52f77b275 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "2–10 people", + "ja": "2〜10人", + "zh-CN": "2-10人", + "zh-TW": "2-10人", + "ko-KR": "2-10명", + "no": "2–10 personer", + "ar": "2-10 أشخاص", + "de": "2–10 Personen", + "fr": "2–10 personnes", + "it": "2–10 persone", + "pt": "2–10 pessoas", + "es": "2–10 personas", + "tr": "2–10 kişi", + "uk": "2–10 осіб" + }, + "ONBOARDING$ORG_11_50": { + "en": "11–50 people", + "ja": "11〜50人", + "zh-CN": "11-50人", + "zh-TW": "11-50人", + "ko-KR": "11-50명", + "no": "11–50 personer", + "ar": "11-50 شخصاً", + "de": "11–50 Personen", + "fr": "11–50 personnes", + "it": "11–50 persone", + "pt": "11–50 pessoas", + "es": "11–50 personas", + "tr": "11–50 kişi", + "uk": "11–50 осіб" + }, + "ONBOARDING$ORG_51_200": { + "en": "51–200 people", + "ja": "51〜200人", + "zh-CN": "51-200人", + "zh-TW": "51-200人", + "ko-KR": "51-200명", + "no": "51–200 personer", + "ar": "51-200 شخصاً", + "de": "51–200 Personen", + "fr": "51–200 personnes", + "it": "51–200 persone", + "pt": "51–200 pessoas", + "es": "51–200 personas", + "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_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": "Завершити" } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index db712dc873..3c884347d3 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -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"), diff --git a/frontend/src/routes/onboarding-form.tsx b/frontend/src/routes/onboarding-form.tsx new file mode 100644 index 0000000000..e019e225fa --- /dev/null +++ b/frontend/src/routes/onboarding-form.tsx @@ -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>( + {}, + ); + + 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 ( + +
+
+ +
+ + +
+ {!isFirstStep && ( + + {t(I18nKey.ONBOARDING$BACK_BUTTON)} + + )} + + {t( + isLastStep + ? I18nKey.ONBOARDING$FINISH_BUTTON + : I18nKey.ONBOARDING$NEXT_BUTTON, + )} + +
+
+
+ ); +} + +export default OnboardingForm; diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 5c6ef5e76f..d8383ec8ba 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -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; diff --git a/frontend/src/utils/feature-flags.ts b/frontend/src/utils/feature-flags.ts index db98c2d221..28fda86fc2 100644 --- a/frontend/src/utils/feature-flags.ts +++ b/frontend/src/utils/feature-flags.ts @@ -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"); diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 42ce972d11..aadbdd10b1 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -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");