mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
64
frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
Normal file
64
frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
frontend/src/components/features/onboarding/step-content.tsx
Normal file
35
frontend/src/components/features/onboarding/step-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/features/onboarding/step-header.tsx
Normal file
31
frontend/src/components/features/onboarding/step-header.tsx
Normal 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;
|
||||
30
frontend/src/components/features/onboarding/step-option.tsx
Normal file
30
frontend/src/components/features/onboarding/step-option.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/hooks/mutation/use-submit-onboarding.ts
Normal file
36
frontend/src/hooks/mutation/use-submit-onboarding.ts
Normal 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 = "/";
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
17
frontend/src/hooks/use-is-on-intermediate-page.ts
Normal file
17
frontend/src/hooks/use-is-on-intermediate-page.ts
Normal 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],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "Завершити"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
243
frontend/src/routes/onboarding-form.tsx
Normal file
243
frontend/src/routes/onboarding-form.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user