diff --git a/frontend/__tests__/components/features/auth/login-cta.test.tsx b/frontend/__tests__/components/features/auth/login-cta.test.tsx
index 1a17d6d285..76a39bd6c2 100644
--- a/frontend/__tests__/components/features/auth/login-cta.test.tsx
+++ b/frontend/__tests__/components/features/auth/login-cta.test.tsx
@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
+import { createRoutesStub } from "react-router";
import { LoginCTA } from "#/components/features/auth/login-cta";
// Mock useTracking hook
@@ -16,8 +17,23 @@ describe("LoginCTA", () => {
vi.clearAllMocks();
});
+ const renderWithRouter = () => {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ Component: LoginCTA,
+ },
+ {
+ path: "/information-request",
+ Component: () =>
,
+ },
+ ]);
+
+ return render();
+ };
+
it("should render enterprise CTA with title and description", () => {
- render();
+ renderWithRouter();
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
@@ -25,7 +41,7 @@ describe("LoginCTA", () => {
});
it("should render all enterprise feature list items", () => {
- render();
+ renderWithRouter();
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
@@ -33,23 +49,9 @@ describe("LoginCTA", () => {
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
});
- it("should render Learn More as a link with correct href and target", () => {
- render();
-
- const learnMoreLink = screen.getByRole("link", {
- name: "CTA$LEARN_MORE",
- });
- expect(learnMoreLink).toHaveAttribute(
- "href",
- "https://openhands.dev/enterprise/",
- );
- expect(learnMoreLink).toHaveAttribute("target", "_blank");
- expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
- });
-
- it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
+ it("should track and navigate to information request page when Learn More is clicked", async () => {
const user = userEvent.setup();
- render();
+ renderWithRouter();
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
@@ -59,5 +61,18 @@ describe("LoginCTA", () => {
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "login_page",
});
+ expect(screen.getByTestId("information-request-page")).toBeInTheDocument();
+ });
+
+ it("should render Learn More as a link for Open in New Tab support", () => {
+ renderWithRouter();
+
+ const learnMoreLink = screen.getByRole("link", {
+ name: "CTA$LEARN_MORE",
+ });
+ expect(learnMoreLink).toHaveAttribute(
+ "href",
+ "/information-request",
+ );
});
});
diff --git a/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx b/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx
new file mode 100644
index 0000000000..1cc6541a55
--- /dev/null
+++ b/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx
@@ -0,0 +1,83 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { MemoryRouter } from "react-router";
+import { EnterpriseCard } from "#/components/features/onboarding/enterprise-card";
+
+describe("EnterpriseCard", () => {
+ const defaultProps = {
+ icon: ,
+ title: "Test Title",
+ description: "Test description",
+ features: ["Feature 1", "Feature 2"],
+ learnMoreLabel: "Learn More",
+ onLearnMore: vi.fn(),
+ };
+
+ const renderWithRouter = (props = defaultProps) =>
+ render(
+
+
+ ,
+ );
+
+ it("should render the card with title", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("Test Title")).toBeInTheDocument();
+ });
+
+ it("should render the description", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("Test description")).toBeInTheDocument();
+ });
+
+ it("should render the icon", () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId("test-icon")).toBeInTheDocument();
+ });
+
+ it("should render the features", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("Feature 1")).toBeInTheDocument();
+ expect(screen.getByText("Feature 2")).toBeInTheDocument();
+ });
+
+ it("should render the learn more link with correct label", () => {
+ renderWithRouter();
+
+ const link = screen.getByRole("link", {
+ name: "Learn More Test Title",
+ });
+ expect(link).toBeInTheDocument();
+ });
+
+ it("should have correct href", () => {
+ renderWithRouter();
+
+ const link = screen.getByRole("link", { name: "Learn More Test Title" });
+ expect(link).toHaveAttribute("href", "/information-request");
+ });
+
+ it("should call onLearnMore when link is clicked", async () => {
+ const mockOnLearnMore = vi.fn();
+ const user = userEvent.setup();
+
+ renderWithRouter({ ...defaultProps, onLearnMore: mockOnLearnMore });
+
+ const link = screen.getByRole("link", { name: "Learn More Test Title" });
+ await user.click(link);
+
+ expect(mockOnLearnMore).toHaveBeenCalledTimes(1);
+ });
+
+ it("should have correct aria-label on link", () => {
+ renderWithRouter();
+
+ const link = screen.getByRole("link");
+ expect(link).toHaveAttribute("aria-label", "Learn More Test Title");
+ });
+});
diff --git a/frontend/__tests__/components/features/onboarding/feature-list.test.tsx b/frontend/__tests__/components/features/onboarding/feature-list.test.tsx
new file mode 100644
index 0000000000..046e0c602f
--- /dev/null
+++ b/frontend/__tests__/components/features/onboarding/feature-list.test.tsx
@@ -0,0 +1,38 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { FeatureList } from "#/components/features/onboarding/feature-list";
+
+describe("FeatureList", () => {
+ it("should render a list of features", () => {
+ const features = ["Feature 1", "Feature 2", "Feature 3"];
+ render();
+
+ expect(screen.getByText("Feature 1")).toBeInTheDocument();
+ expect(screen.getByText("Feature 2")).toBeInTheDocument();
+ expect(screen.getByText("Feature 3")).toBeInTheDocument();
+ });
+
+ it("should render bullet points for each feature", () => {
+ const features = ["Feature 1", "Feature 2"];
+ render();
+
+ const bullets = screen.getAllByText("•");
+ expect(bullets).toHaveLength(2);
+ });
+
+ it("should render an empty list when no features provided", () => {
+ render();
+
+ const list = screen.getByRole("list");
+ expect(list).toBeInTheDocument();
+ expect(list.children).toHaveLength(0);
+ });
+
+ it("should render each feature as a list item", () => {
+ const features = ["Feature 1", "Feature 2"];
+ render();
+
+ const listItems = screen.getAllByRole("listitem");
+ expect(listItems).toHaveLength(2);
+ });
+});
diff --git a/frontend/__tests__/components/features/onboarding/form-input.test.tsx b/frontend/__tests__/components/features/onboarding/form-input.test.tsx
new file mode 100644
index 0000000000..0c180f4d47
--- /dev/null
+++ b/frontend/__tests__/components/features/onboarding/form-input.test.tsx
@@ -0,0 +1,171 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { FormInput } from "#/components/features/onboarding/form-input";
+
+describe("FormInput", () => {
+ const defaultProps = {
+ id: "test-input",
+ label: "Test Label",
+ value: "",
+ onChange: vi.fn(),
+ };
+
+ it("should render with correct test id", () => {
+ render();
+
+ expect(screen.getByTestId("form-input-test-input")).toBeInTheDocument();
+ });
+
+ it("should render the label", () => {
+ render();
+
+ expect(screen.getByText("Test Label")).toBeInTheDocument();
+ });
+
+ it("should display the provided value", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveValue("Hello World");
+ });
+
+ it("should call onChange when user types", async () => {
+ const mockOnChange = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ await user.type(input, "a");
+
+ expect(mockOnChange).toHaveBeenCalledWith("a");
+ });
+
+ it("should render as a text input by default", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("type", "text");
+ });
+
+ it("should render as an email input when type is email", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("type", "email");
+ });
+
+ it("should render a textarea when rows prop is provided", () => {
+ render();
+
+ const textarea = screen.getByTestId("form-input-test-input");
+ expect(textarea.tagName).toBe("TEXTAREA");
+ expect(textarea).toHaveAttribute("rows", "4");
+ });
+
+ it("should render placeholder text", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("placeholder", "Enter text here");
+ });
+
+ it("should have aria-required attribute when required", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-required", "true");
+ });
+
+ it("should have aria-label attribute", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-label", "Test Label");
+ });
+
+ it("should have required attribute on input when required", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toBeRequired();
+ });
+
+ it("should have required attribute on textarea when required", () => {
+ render();
+
+ const textarea = screen.getByTestId("form-input-test-input");
+ expect(textarea).toBeRequired();
+ });
+
+ it("should associate label with input via htmlFor", () => {
+ render();
+
+ const label = screen.getByText("Test Label");
+ const input = screen.getByTestId("form-input-test-input");
+
+ expect(label).toHaveAttribute("for", "form-input-test-input");
+ expect(input).toHaveAttribute("id", "form-input-test-input");
+ });
+
+ describe("error state", () => {
+ it("should have aria-invalid true when showing error", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("should have aria-invalid false when not showing error", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "false");
+ });
+
+ it("should have aria-invalid false when showError is true but field has value", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "false");
+ });
+
+ it("should have aria-invalid false when showError is true but field is not required", () => {
+ render();
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "false");
+ });
+
+ it("should have aria-invalid true on textarea when showError is true and empty", () => {
+ render();
+
+ const textarea = screen.getByTestId("form-input-test-input");
+ expect(textarea).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("should have aria-invalid true for invalid email when showError is true", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("should have aria-invalid false for valid email when showError is true", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveAttribute("aria-invalid", "false");
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx
new file mode 100644
index 0000000000..1082d44a96
--- /dev/null
+++ b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx
@@ -0,0 +1,367 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { createRoutesStub } from "react-router";
+import { useState } from "react";
+import {
+ InformationRequestForm,
+ RequestType,
+} from "#/components/features/onboarding/information-request-form";
+import { EnterpriseFormData } from "#/utils/local-storage";
+
+// Mock useTracking
+const mockTrackEnterpriseLeadFormSubmitted = vi.fn();
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackEnterpriseLeadFormSubmitted: mockTrackEnterpriseLeadFormSubmitted,
+ }),
+}));
+
+const mockOnBack = vi.fn();
+
+// Wrapper to manage form state (needed since component is controlled)
+function StatefulForm({ requestType }: { requestType: RequestType }) {
+ const [formData, setFormData] = useState({ name: "", company: "", email: "", message: "" });
+ return ;
+}
+
+describe("InformationRequestForm", () => {
+ const defaultProps = {
+ requestType: "saas" as RequestType,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockOnBack.mockClear();
+ });
+
+ const renderWithRouter = (props = defaultProps) => {
+ const Stub = createRoutesStub([
+ {
+ path: "/",
+ Component: () => ,
+ },
+ {
+ path: "/login",
+ Component: () => ,
+ },
+ {
+ path: "/information-request",
+ Component: () => ,
+ },
+ ]);
+
+ return render();
+ };
+
+ it("should render the form", () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+ });
+
+ it("should render the logo", () => {
+ renderWithRouter();
+
+ const logo = screen.getByTestId("information-request-form").querySelector("svg");
+ expect(logo).toBeInTheDocument();
+ });
+
+ it("should render all form fields", () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId("form-input-name")).toBeInTheDocument();
+ expect(screen.getByTestId("form-input-company")).toBeInTheDocument();
+ expect(screen.getByTestId("form-input-email")).toBeInTheDocument();
+ expect(screen.getByTestId("form-input-message")).toBeInTheDocument();
+ });
+
+ it("should render SaaS-specific title when requestType is saas", () => {
+ renderWithRouter({ ...defaultProps, requestType: "saas" });
+
+ expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument();
+ });
+
+ it("should render Self-hosted-specific title when requestType is self-hosted", () => {
+ renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
+
+ expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument();
+ });
+
+ it("should render cloud icon for SaaS request type", () => {
+ renderWithRouter({ ...defaultProps, requestType: "saas" });
+
+ // The card should contain the cloud icon
+ const card = screen.getByText("ENTERPRISE$SAAS_TITLE").closest("div");
+ expect(card).toBeInTheDocument();
+ });
+
+ it("should render stacked icon for self-hosted request type", () => {
+ renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
+
+ // The card should contain the stacked icon
+ const card = screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE").closest("div");
+ expect(card).toBeInTheDocument();
+ });
+
+ it("should call onBack when back button is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithRouter();
+
+ const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
+ await user.click(backButton);
+
+ expect(mockOnBack).toHaveBeenCalledTimes(1);
+ });
+
+ it("should update form fields when user types", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ const nameInput = screen.getByTestId("form-input-name");
+ await user.type(nameInput, "John Doe");
+
+ expect(nameInput).toHaveValue("John Doe");
+ });
+
+ it("should update email field when user types", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ const emailInput = screen.getByTestId("form-input-email");
+ await user.type(emailInput, "john@example.com");
+
+ expect(emailInput).toHaveValue("john@example.com");
+ });
+
+ it("should render message as textarea", () => {
+ renderWithRouter();
+
+ const messageInput = screen.getByTestId("form-input-message");
+ expect(messageInput.tagName).toBe("TEXTAREA");
+ });
+
+ it("should have all fields marked as required", () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId("form-input-name")).toBeRequired();
+ expect(screen.getByTestId("form-input-company")).toBeRequired();
+ expect(screen.getByTestId("form-input-email")).toBeRequired();
+ expect(screen.getByTestId("form-input-message")).toBeRequired();
+ });
+
+ it("should render submit button", () => {
+ renderWithRouter();
+
+ const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
+ expect(submitButton).toBeInTheDocument();
+ expect(submitButton).toHaveAttribute("type", "submit");
+ });
+
+ it("should render back button", () => {
+ renderWithRouter();
+
+ const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
+ expect(backButton).toBeInTheDocument();
+ expect(backButton).toHaveAttribute("type", "button");
+ });
+
+ it("should have button group with role and aria-label", () => {
+ renderWithRouter();
+
+ const buttonGroup = screen.getByRole("group", { name: "Form actions" });
+ expect(buttonGroup).toBeInTheDocument();
+ });
+
+ it("should display SaaS card description for saas request type", () => {
+ renderWithRouter({ ...defaultProps, requestType: "saas" });
+
+ expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument();
+ });
+
+ it("should display Self-hosted card description for self-hosted request type", () => {
+ renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
+
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_DESCRIPTION")).toBeInTheDocument();
+ });
+
+ describe("form validation", () => {
+ it("should not show error state before form submission", () => {
+ renderWithRouter();
+
+ const nameInput = screen.getByTestId("form-input-name");
+ const companyInput = screen.getByTestId("form-input-company");
+ const emailInput = screen.getByTestId("form-input-email");
+ const messageInput = screen.getByTestId("form-input-message");
+
+ expect(nameInput).toHaveAttribute("aria-invalid", "false");
+ expect(companyInput).toHaveAttribute("aria-invalid", "false");
+ expect(emailInput).toHaveAttribute("aria-invalid", "false");
+ expect(messageInput).toHaveAttribute("aria-invalid", "false");
+ });
+
+ it("should not navigate when form is submitted with empty fields", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ const submitButton = screen.getByRole("button", {
+ name: "ENTERPRISE$FORM_SUBMIT",
+ });
+ await user.click(submitButton);
+
+ // Should stay on form page, not navigate to login
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+ expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
+ });
+
+ it("should not call tracking when form is submitted with empty fields", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
+ await user.click(submitButton);
+
+ expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
+ });
+
+ it("should navigate to login page when form is submitted with all fields filled", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.type(screen.getByTestId("form-input-name"), "John Doe");
+ await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
+ await user.type(screen.getByTestId("form-input-email"), "john@example.com");
+ await user.type(screen.getByTestId("form-input-message"), "Hello world");
+
+ const submitButton = screen.getByRole("button", {
+ name: "ENTERPRISE$FORM_SUBMIT",
+ });
+ await user.click(submitButton);
+
+ // Should navigate to login page
+ expect(screen.getByTestId("login-page")).toBeInTheDocument();
+ });
+
+ it("should call tracking with form data when form is submitted successfully", async () => {
+ const user = userEvent.setup();
+ renderWithRouter({ ...defaultProps, requestType: "saas" });
+
+ await user.type(screen.getByTestId("form-input-name"), "John Doe");
+ await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
+ await user.type(screen.getByTestId("form-input-email"), "john@example.com");
+ await user.type(screen.getByTestId("form-input-message"), "Hello world");
+
+ const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
+ await user.click(submitButton);
+
+ expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
+ expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
+ requestType: "saas",
+ name: "John Doe",
+ company: "Acme Inc",
+ email: "john@example.com",
+ message: "Hello world",
+ });
+ });
+
+ it("should call tracking with self-hosted request type", async () => {
+ const user = userEvent.setup();
+ renderWithRouter({ ...defaultProps, requestType: "self-hosted" });
+
+ await user.type(screen.getByTestId("form-input-name"), "Jane Smith");
+ await user.type(screen.getByTestId("form-input-company"), "Tech Corp");
+ await user.type(screen.getByTestId("form-input-email"), "jane@techcorp.com");
+ await user.type(screen.getByTestId("form-input-message"), "Interested in self-hosted");
+
+ const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
+ await user.click(submitButton);
+
+ expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
+ requestType: "self-hosted",
+ name: "Jane Smith",
+ company: "Tech Corp",
+ email: "jane@techcorp.com",
+ message: "Interested in self-hosted",
+ });
+ });
+
+ it("should trim whitespace from form fields before tracking", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.type(screen.getByTestId("form-input-name"), " John Doe ");
+ await user.type(screen.getByTestId("form-input-company"), " Acme Inc ");
+ await user.type(screen.getByTestId("form-input-email"), " john@example.com ");
+ await user.type(screen.getByTestId("form-input-message"), " Hello world ");
+
+ const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" });
+ await user.click(submitButton);
+
+ expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({
+ requestType: "saas",
+ name: "John Doe",
+ company: "Acme Inc",
+ email: "john@example.com",
+ message: "Hello world",
+ });
+ });
+
+ it("should have valid aria-invalid state when field has value", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ const nameInput = screen.getByTestId("form-input-name");
+ await user.type(nameInput, "John Doe");
+
+ // Field with value should not be invalid
+ expect(nameInput).toHaveAttribute("aria-invalid", "false");
+ });
+
+ it("should not navigate when email is invalid", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.type(screen.getByTestId("form-input-name"), "John Doe");
+ await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
+ await user.type(screen.getByTestId("form-input-email"), "invalid-email");
+ await user.type(screen.getByTestId("form-input-message"), "Hello world");
+
+ const submitButton = screen.getByRole("button", {
+ name: "ENTERPRISE$FORM_SUBMIT",
+ });
+ await user.click(submitButton);
+
+ // Should stay on form page, not navigate to login
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+ expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
+ expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("loading state", () => {
+ it("should prevent double submission", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ await user.type(screen.getByTestId("form-input-name"), "John Doe");
+ await user.type(screen.getByTestId("form-input-company"), "Acme Inc");
+ await user.type(screen.getByTestId("form-input-email"), "john@example.com");
+ await user.type(screen.getByTestId("form-input-message"), "Hello world");
+
+ const submitButton = screen.getByRole("button", {
+ name: "ENTERPRISE$FORM_SUBMIT",
+ });
+
+ // Click multiple times rapidly
+ await user.click(submitButton);
+ await user.click(submitButton);
+ await user.click(submitButton);
+
+ // Should only track once
+ expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1);
+ // Should navigate to login page
+ expect(screen.getByTestId("login-page")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx b/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx
new file mode 100644
index 0000000000..d5c40bc4eb
--- /dev/null
+++ b/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx
@@ -0,0 +1,113 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal";
+
+describe("RequestSubmittedModal", () => {
+ const defaultProps = {
+ onClose: vi.fn(),
+ };
+
+ it("should render the modal", () => {
+ render();
+
+ expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
+ });
+
+ it("should render the title", () => {
+ render();
+
+ expect(
+ screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render the description", () => {
+ render();
+
+ expect(
+ screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"),
+ ).toBeInTheDocument();
+ });
+
+ it("should render the Done button", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }),
+ ).toBeInTheDocument();
+ });
+
+ it("should render the close button", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: "MODAL$CLOSE_BUTTON_LABEL" }),
+ ).toBeInTheDocument();
+ });
+
+ it("should call onClose when Done button is clicked", async () => {
+ const mockOnClose = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ const doneButton = screen.getByRole("button", {
+ name: "ENTERPRISE$DONE_BUTTON",
+ });
+ await user.click(doneButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose when close button is clicked", async () => {
+ const mockOnClose = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ const closeButton = screen.getByRole("button", {
+ name: "MODAL$CLOSE_BUTTON_LABEL",
+ });
+ await user.click(closeButton);
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose when Escape key is pressed", async () => {
+ const mockOnClose = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ await user.keyboard("{Escape}");
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose when backdrop is clicked", async () => {
+ const mockOnClose = vi.fn();
+ const user = userEvent.setup();
+
+ render();
+
+ // Click on the backdrop (the semi-transparent overlay)
+ const backdrop = screen.getByRole("dialog").querySelector(".bg-black");
+ if (backdrop) {
+ await user.click(backdrop);
+ }
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should have proper accessibility attributes", () => {
+ render();
+
+ const dialog = screen.getByRole("dialog");
+ expect(dialog).toHaveAttribute("aria-modal", "true");
+ expect(dialog).toHaveAttribute(
+ "aria-label",
+ "ENTERPRISE$REQUEST_SUBMITTED_TITLE",
+ );
+ });
+});
diff --git a/frontend/__tests__/components/features/onboarding/step-input.test.tsx b/frontend/__tests__/components/features/onboarding/step-input.test.tsx
deleted file mode 100644
index a4f388efbb..0000000000
--- a/frontend/__tests__/components/features/onboarding/step-input.test.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import { StepInput } from "#/components/features/onboarding/step-input";
-
-describe("StepInput", () => {
- const defaultProps = {
- id: "test-input",
- label: "Test Label",
- value: "",
- onChange: vi.fn(),
- };
-
- it("should render with correct test id", () => {
- render();
-
- expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
- });
-
- it("should render the label", () => {
- render();
-
- expect(screen.getByText("Test Label")).toBeInTheDocument();
- });
-
- it("should display the provided value", () => {
- render();
-
- const input = screen.getByTestId("step-input-test-input");
- expect(input).toHaveValue("Hello World");
- });
-
- it("should call onChange when user types", async () => {
- const mockOnChange = vi.fn();
- const user = userEvent.setup();
-
- render();
-
- const input = screen.getByTestId("step-input-test-input");
- await user.type(input, "a");
-
- expect(mockOnChange).toHaveBeenCalledWith("a");
- });
-
- it("should call onChange with the full input value on each keystroke", async () => {
- const mockOnChange = vi.fn();
- const user = userEvent.setup();
-
- render();
-
- const input = screen.getByTestId("step-input-test-input");
- await user.type(input, "abc");
-
- expect(mockOnChange).toHaveBeenCalledTimes(3);
- expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
- expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
- expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
- });
-
- it("should use the id prop for data-testid", () => {
- render();
-
- expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
- });
-
- it("should render as a text input", () => {
- render();
-
- const input = screen.getByTestId("step-input-test-input");
- expect(input).toHaveAttribute("type", "text");
- });
-});
diff --git a/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
index 2879815745..2c9ecdca18 100644
--- a/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
+++ b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts
@@ -32,6 +32,12 @@ describe("useIsOnIntermediatePage", () => {
const { result } = renderHook(() => useIsOnIntermediatePage());
expect(result.current).toBe(true);
});
+
+ it("should return true when on /information-request page", () => {
+ useLocationMock.mockReturnValue({ pathname: "/information-request" });
+ const { result } = renderHook(() => useIsOnIntermediatePage());
+ expect(result.current).toBe(true);
+ });
});
describe("returns false for non-intermediate pages", () => {
diff --git a/frontend/__tests__/routes/information-request.test.tsx b/frontend/__tests__/routes/information-request.test.tsx
new file mode 100644
index 0000000000..c6b04e9396
--- /dev/null
+++ b/frontend/__tests__/routes/information-request.test.tsx
@@ -0,0 +1,174 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { MemoryRouter } from "react-router";
+import InformationRequest from "#/routes/information-request";
+
+// Mock useNavigate
+const mockNavigate = vi.fn();
+vi.mock("react-router", async () => {
+ const actual = await vi.importActual("react-router");
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock useTracking to avoid QueryClient dependency
+vi.mock("#/hooks/use-tracking", () => ({
+ useTracking: () => ({
+ trackEnterpriseLeadFormSubmitted: vi.fn(),
+ }),
+}));
+
+describe("InformationRequest", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const renderWithRouter = () => {
+ return render(
+
+
+ ,
+ );
+ };
+
+ it("should render the page", () => {
+ renderWithRouter();
+
+ expect(screen.getByTestId("information-request-page")).toBeInTheDocument();
+ });
+
+ it("should render the logo", () => {
+ renderWithRouter();
+
+ const page = screen.getByTestId("information-request-page");
+ const logo = page.querySelector("svg");
+ expect(logo).toBeInTheDocument();
+ });
+
+ it("should render the page title", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_TITLE")).toBeInTheDocument();
+ });
+
+ it("should render the page subtitle", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_SUBTITLE")).toBeInTheDocument();
+ });
+
+ it("should render SaaS card", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$SAAS_TITLE")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument();
+ });
+
+ it("should render Self-hosted card", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION")).toBeInTheDocument();
+ });
+
+ it("should render SaaS features", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_SSO")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES")).toBeInTheDocument();
+ });
+
+ it("should render Self-hosted features", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT")).toBeInTheDocument();
+ });
+
+ it("should render two Learn More buttons", () => {
+ renderWithRouter();
+
+ const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE");
+ expect(learnMoreButtons).toHaveLength(2);
+ });
+
+ it("should render back button", () => {
+ renderWithRouter();
+
+ expect(screen.getByText("COMMON$BACK")).toBeInTheDocument();
+ });
+
+ it("should have back link pointing to /login", () => {
+ renderWithRouter();
+
+ const backLink = screen.getByText("COMMON$BACK");
+ expect(backLink).toHaveAttribute("href", "/login");
+ });
+
+ it("should show SaaS form when SaaS Learn More is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ // Click the first Learn More button (SaaS)
+ const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE");
+ await user.click(learnMoreButtons[0]);
+
+ // Form should now be visible
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument();
+ });
+
+ it("should show Self-hosted form when Self-hosted Learn More is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ // Click the second Learn More button (Self-hosted)
+ const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE");
+ await user.click(learnMoreButtons[1]);
+
+ // Form should now be visible
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument();
+ });
+
+ it("should return to card selection when form back button is clicked", async () => {
+ const user = userEvent.setup();
+ renderWithRouter();
+
+ // Click Learn More to show form
+ const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE");
+ await user.click(learnMoreButtons[0]);
+
+ // Verify form is visible
+ expect(screen.getByTestId("information-request-form")).toBeInTheDocument();
+
+ // Click back button
+ const backButton = screen.getByRole("button", { name: "COMMON$BACK" });
+ await user.click(backButton);
+
+ // Should return to card selection view
+ expect(screen.queryByTestId("information-request-form")).not.toBeInTheDocument();
+ expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_TITLE")).toBeInTheDocument();
+ });
+
+ it("should have accessible Learn More links with aria-label", () => {
+ renderWithRouter();
+
+ const saasLink = screen.getByRole("link", {
+ name: "ENTERPRISE$LEARN_MORE ENTERPRISE$SAAS_TITLE",
+ });
+ const selfHostedLink = screen.getByRole("link", {
+ name: "ENTERPRISE$LEARN_MORE ENTERPRISE$SELF_HOSTED_TITLE",
+ });
+
+ expect(saasLink).toBeInTheDocument();
+ expect(selfHostedLink).toBeInTheDocument();
+ });
+});
diff --git a/frontend/__tests__/ui/card.test.tsx b/frontend/__tests__/ui/card.test.tsx
new file mode 100644
index 0000000000..3445878239
--- /dev/null
+++ b/frontend/__tests__/ui/card.test.tsx
@@ -0,0 +1,154 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Card } from "#/ui/card";
+
+describe("Card", () => {
+ it("should render children", () => {
+ render(Card Content);
+
+ expect(screen.getByText("Card Content")).toBeInTheDocument();
+ });
+
+ it("should render with testId", () => {
+ render(Content);
+
+ expect(screen.getByTestId("test-card")).toBeInTheDocument();
+ });
+
+ it("should apply custom className", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ expect(screen.getByTestId("test-card")).toHaveClass("custom-class");
+ });
+
+ describe("theme variants", () => {
+ it("should apply default theme styles", () => {
+ render(Content);
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("bg-[#26282D]");
+ expect(card).toHaveClass("border-[#727987]");
+ expect(card).toHaveClass("rounded-xl");
+ });
+
+ it("should apply outlined theme styles", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("bg-transparent");
+ expect(card).toHaveClass("border-[#727987]");
+ });
+
+ it("should apply dark theme styles", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("bg-black");
+ expect(card).toHaveClass("border-[#242424]");
+ expect(card).toHaveClass("rounded-2xl");
+ });
+ });
+
+ describe("hover variants", () => {
+ it("should not apply hover styles when hover is none", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).not.toHaveClass("hover:bg-[linear-gradient");
+ });
+
+ it("should apply elevated hover styles", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("transition-all");
+ expect(card).toHaveClass("duration-200");
+ });
+ });
+
+ describe("gradient variants", () => {
+ it("should not apply gradient styles when gradient is none", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).not.toHaveClass("bg-[#0A0A0A80]");
+ });
+
+ it("should apply standard gradient styles", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("bg-[#0A0A0A80]");
+ expect(card).toHaveClass("border-t-[#24242499]");
+ });
+ });
+
+ describe("combined variants", () => {
+ it("should apply dark theme with standard gradient", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ // Should have dark theme base
+ expect(card).toHaveClass("border-[#242424]");
+ expect(card).toHaveClass("rounded-2xl");
+ // Should have gradient overlay
+ expect(card).toHaveClass("bg-[#0A0A0A80]");
+ });
+
+ it("should apply dark theme with elevated hover", () => {
+ render(
+
+ Content
+ ,
+ );
+
+ const card = screen.getByTestId("test-card");
+ expect(card).toHaveClass("rounded-2xl");
+ expect(card).toHaveClass("transition-all");
+ });
+ });
+
+ it("should have flex display by default", () => {
+ render(Content);
+
+ expect(screen.getByTestId("test-card")).toHaveClass("flex");
+ });
+
+ it("should have relative positioning", () => {
+ render(Content);
+
+ expect(screen.getByTestId("test-card")).toHaveClass("relative");
+ });
+});
diff --git a/frontend/src/components/features/auth/login-cta.tsx b/frontend/src/components/features/auth/login-cta.tsx
index 1d67b9cedb..06823837c3 100644
--- a/frontend/src/components/features/auth/login-cta.tsx
+++ b/frontend/src/components/features/auth/login-cta.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
+import { Link } from "react-router";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
@@ -44,21 +45,20 @@ export function LoginCTA() {
diff --git a/frontend/src/components/features/onboarding/enterprise-card.tsx b/frontend/src/components/features/onboarding/enterprise-card.tsx
new file mode 100644
index 0000000000..e4dce5fa85
--- /dev/null
+++ b/frontend/src/components/features/onboarding/enterprise-card.tsx
@@ -0,0 +1,54 @@
+import { Link } from "react-router";
+import { Card } from "#/ui/card";
+import { Typography } from "#/ui/typography";
+import { cn } from "#/utils/utils";
+import { FeatureList } from "./feature-list";
+
+interface EnterpriseCardProps {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ features: string[];
+ onLearnMore: () => void;
+ learnMoreLabel: string;
+}
+
+export function EnterpriseCard({
+ icon,
+ title,
+ description,
+ features,
+ onLearnMore,
+ learnMoreLabel,
+}: EnterpriseCardProps) {
+ return (
+
+ {icon}
+
+ {title}
+
+
+ {description}
+
+
+
+ {learnMoreLabel}
+
+
+ );
+}
diff --git a/frontend/src/components/features/onboarding/feature-list.tsx b/frontend/src/components/features/onboarding/feature-list.tsx
new file mode 100644
index 0000000000..3327fe7efc
--- /dev/null
+++ b/frontend/src/components/features/onboarding/feature-list.tsx
@@ -0,0 +1,20 @@
+import { Typography } from "#/ui/typography";
+
+interface FeatureListProps {
+ features: string[];
+}
+
+export function FeatureList({ features }: FeatureListProps) {
+ return (
+
+ {features.map((feature, index) => (
+ -
+ •
+
+ {feature}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/features/onboarding/form-input.tsx b/frontend/src/components/features/onboarding/form-input.tsx
new file mode 100644
index 0000000000..a21bc6b629
--- /dev/null
+++ b/frontend/src/components/features/onboarding/form-input.tsx
@@ -0,0 +1,78 @@
+import { isValidEmail } from "#/utils/input-validation";
+import { cn } from "#/utils/utils";
+
+// Email validation pattern - must match EMAIL_REGEX in input-validation.ts
+const EMAIL_PATTERN = "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}";
+
+interface FormInputProps {
+ id: string;
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ type?: "text" | "email";
+ rows?: number;
+ required?: boolean;
+ showError?: boolean;
+}
+
+export function FormInput({
+ id,
+ label,
+ value,
+ onChange,
+ placeholder,
+ type = "text",
+ rows,
+ required = false,
+ showError = false,
+}: FormInputProps) {
+ const inputId = `form-input-${id}`;
+ const isEmailInvalid =
+ type === "email" && !!value.trim() && !isValidEmail(value.trim());
+ const hasError = showError && ((required && !value.trim()) || isEmailInvalid);
+ const baseClassName = cn(
+ "w-full min-h-10 rounded border border-[#242424] px-3 py-2 text-sm leading-5 text-white placeholder:text-[#8C8C8C] placeholder:leading-5 focus:outline-none transition-colors focus:border-white",
+ );
+
+ return (
+
+
+ {rows ? (
+
+ );
+}
diff --git a/frontend/src/components/features/onboarding/information-request-form.tsx b/frontend/src/components/features/onboarding/information-request-form.tsx
new file mode 100644
index 0000000000..b7f46164e0
--- /dev/null
+++ b/frontend/src/components/features/onboarding/information-request-form.tsx
@@ -0,0 +1,230 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+import { I18nKey } from "#/i18n/declaration";
+import { useTracking } from "#/hooks/use-tracking";
+import { Card } from "#/ui/card";
+import { Typography } from "#/ui/typography";
+import {
+ clearEnterpriseFormData,
+ EnterpriseFormData,
+} from "#/utils/local-storage";
+import { cn } from "#/utils/utils";
+import { FormInput } from "./form-input";
+import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
+import CloudIcon from "#/icons/cloud-minimal.svg?react";
+import StackedIcon from "#/icons/stacked.svg?react";
+
+export type RequestType = "saas" | "self-hosted";
+
+interface InformationRequestFormProps {
+ requestType: RequestType;
+ formData: EnterpriseFormData;
+ onFormDataChange: (data: EnterpriseFormData) => void;
+ onBack: () => void;
+}
+
+export function InformationRequestForm({
+ requestType,
+ formData,
+ onFormDataChange,
+ onBack,
+}: InformationRequestFormProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { trackEnterpriseLeadFormSubmitted } = useTracking();
+ const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (isSubmitting) return;
+ setHasAttemptedSubmit(true);
+
+ // Use native form validation to show browser error popups
+ const form = e.currentTarget;
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ trackEnterpriseLeadFormSubmitted({
+ requestType,
+ name: formData.name.trim(),
+ company: formData.company.trim(),
+ email: formData.email.trim(),
+ message: formData.message.trim(),
+ });
+
+ // Clear form data from localStorage and reset form state
+ clearEnterpriseFormData(requestType);
+ onFormDataChange({ name: "", company: "", email: "", message: "" });
+
+ // Navigate to login page with state to show confirmation modal
+ navigate("/login", { state: { showRequestSubmittedModal: true } });
+ };
+
+ const isSaas = requestType === "saas";
+
+ const title = isSaas
+ ? t(I18nKey.ENTERPRISE$FORM_SAAS_TITLE)
+ : t(I18nKey.ENTERPRISE$FORM_SELF_HOSTED_TITLE);
+
+ const subtitle = isSaas
+ ? t(I18nKey.ENTERPRISE$FORM_SAAS_SUBTITLE)
+ : t(I18nKey.ENTERPRISE$FORM_SELF_HOSTED_SUBTITLE);
+
+ const cardTitle = isSaas
+ ? t(I18nKey.ENTERPRISE$SAAS_TITLE)
+ : t(I18nKey.ENTERPRISE$SELF_HOSTED_TITLE);
+
+ const cardDescription = isSaas
+ ? t(I18nKey.ENTERPRISE$SAAS_DESCRIPTION)
+ : t(I18nKey.ENTERPRISE$SELF_HOSTED_DESCRIPTION);
+
+ const messagePlaceholder = isSaas
+ ? t(I18nKey.ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER)
+ : t(I18nKey.ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER);
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ {/* Content: Form + Card */}
+
+ {/* Form */}
+
+
+ {/* CTA Card */}
+
+
+ {isSaas ? (
+
+ ) : (
+
+ )}
+
+
+ {cardTitle}
+
+
+ {cardDescription}
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/onboarding/request-submitted-modal.tsx b/frontend/src/components/features/onboarding/request-submitted-modal.tsx
new file mode 100644
index 0000000000..e9fbd99c76
--- /dev/null
+++ b/frontend/src/components/features/onboarding/request-submitted-modal.tsx
@@ -0,0 +1,72 @@
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
+import { Typography } from "#/ui/typography";
+import { cn } from "#/utils/utils";
+import CloseIcon from "#/icons/modal-close.svg?react";
+
+interface RequestSubmittedModalProps {
+ onClose: () => void;
+}
+
+export function RequestSubmittedModal({ onClose }: RequestSubmittedModalProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {/* Header with close button */}
+
+
+
+ {/* Title and description */}
+
+
+ {t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_TITLE)}
+
+
+ {t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION)}
+
+
+
+
+ {/* Footer with Done button */}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/features/onboarding/step-content.tsx b/frontend/src/components/features/onboarding/step-content.tsx
index 3f99e54389..acc6e20e4f 100644
--- a/frontend/src/components/features/onboarding/step-content.tsx
+++ b/frontend/src/components/features/onboarding/step-content.tsx
@@ -1,5 +1,5 @@
import { StepOption } from "./step-option";
-import { StepInput } from "./step-input";
+import { FormInput } from "./form-input";
export interface Option {
id: string;
@@ -43,7 +43,7 @@ export function StepContent({
/>
))}
{inputFields?.map((field) => (
- void;
-}
-
-export function StepInput({ id, label, value, onChange }: StepInputProps) {
- return (
-
-
- onChange(e.target.value)}
- className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors"
- />
-
- );
-}
diff --git a/frontend/src/hooks/use-is-on-intermediate-page.ts b/frontend/src/hooks/use-is-on-intermediate-page.ts
index 955fdc28ee..dbe6ed6025 100644
--- a/frontend/src/hooks/use-is-on-intermediate-page.ts
+++ b/frontend/src/hooks/use-is-on-intermediate-page.ts
@@ -1,6 +1,10 @@
import { useLocation } from "react-router";
-const INTERMEDIATE_PAGE_PATHS = ["/accept-tos", "/onboarding"];
+const INTERMEDIATE_PAGE_PATHS = [
+ "/accept-tos",
+ "/onboarding",
+ "/information-request",
+];
/**
* Checks if the current page is an intermediate page.
diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts
index a81aa46ccc..63fcbe2d24 100644
--- a/frontend/src/hooks/use-tracking.ts
+++ b/frontend/src/hooks/use-tracking.ts
@@ -129,6 +129,29 @@ export const useTracking = () => {
});
};
+ const trackEnterpriseLeadFormSubmitted = ({
+ requestType,
+ name,
+ company,
+ email,
+ message,
+ }: {
+ requestType: "saas" | "self-hosted";
+ name: string;
+ company: string;
+ email: string;
+ message: string;
+ }) => {
+ posthog.capture("enterprise_lead_form_submitted", {
+ request_type: requestType,
+ name,
+ company,
+ email,
+ message,
+ ...commonProperties,
+ });
+ };
+
return {
trackLoginButtonClick,
trackConversationCreated,
@@ -142,5 +165,6 @@ export const useTracking = () => {
trackAddTeamMembersButtonClick,
trackOnboardingCompleted,
trackSaasSelfhostedInquiry,
+ trackEnterpriseLeadFormSubmitted,
};
};
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index 8fc4df82d8..ce1ad9bb6b 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -1100,14 +1100,6 @@ export enum I18nKey {
LAUNCH$PLUGIN_REF = "LAUNCH$PLUGIN_REF",
LAUNCH$PLUGIN_PATH = "LAUNCH$PLUGIN_PATH",
LAUNCH$TRUST_SKILL_CHECKBOX = "LAUNCH$TRUST_SKILL_CHECKBOX",
- 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",
HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE",
HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING",
HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER",
@@ -1214,4 +1206,39 @@ export enum I18nKey {
CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE",
CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION",
CTA$LEARN_MORE = "CTA$LEARN_MORE",
+ ENTERPRISE$GET_OPENHANDS_TITLE = "ENTERPRISE$GET_OPENHANDS_TITLE",
+ ENTERPRISE$GET_OPENHANDS_SUBTITLE = "ENTERPRISE$GET_OPENHANDS_SUBTITLE",
+ ENTERPRISE$SAAS_TITLE = "ENTERPRISE$SAAS_TITLE",
+ ENTERPRISE$SAAS_DESCRIPTION = "ENTERPRISE$SAAS_DESCRIPTION",
+ ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE = "ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE",
+ ENTERPRISE$SAAS_FEATURE_SSO = "ENTERPRISE$SAAS_FEATURE_SSO",
+ ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE = "ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE",
+ ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES = "ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES",
+ ENTERPRISE$SELF_HOSTED_TITLE = "ENTERPRISE$SELF_HOSTED_TITLE",
+ ENTERPRISE$SELF_HOSTED_DESCRIPTION = "ENTERPRISE$SELF_HOSTED_DESCRIPTION",
+ ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION = "ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION",
+ ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES = "ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES",
+ ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL = "ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL",
+ ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE = "ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE",
+ ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT = "ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT",
+ ENTERPRISE$FORM_SAAS_TITLE = "ENTERPRISE$FORM_SAAS_TITLE",
+ ENTERPRISE$FORM_SAAS_SUBTITLE = "ENTERPRISE$FORM_SAAS_SUBTITLE",
+ ENTERPRISE$FORM_SELF_HOSTED_TITLE = "ENTERPRISE$FORM_SELF_HOSTED_TITLE",
+ ENTERPRISE$FORM_SELF_HOSTED_SUBTITLE = "ENTERPRISE$FORM_SELF_HOSTED_SUBTITLE",
+ ENTERPRISE$FORM_NAME_LABEL = "ENTERPRISE$FORM_NAME_LABEL",
+ ENTERPRISE$FORM_NAME_PLACEHOLDER = "ENTERPRISE$FORM_NAME_PLACEHOLDER",
+ ENTERPRISE$FORM_EMAIL_LABEL = "ENTERPRISE$FORM_EMAIL_LABEL",
+ ENTERPRISE$FORM_EMAIL_PLACEHOLDER = "ENTERPRISE$FORM_EMAIL_PLACEHOLDER",
+ ENTERPRISE$FORM_COMPANY_LABEL = "ENTERPRISE$FORM_COMPANY_LABEL",
+ ENTERPRISE$FORM_COMPANY_PLACEHOLDER = "ENTERPRISE$FORM_COMPANY_PLACEHOLDER",
+ ENTERPRISE$FORM_MESSAGE_LABEL = "ENTERPRISE$FORM_MESSAGE_LABEL",
+ ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER = "ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER",
+ ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER = "ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER",
+ ENTERPRISE$FORM_SUBMIT = "ENTERPRISE$FORM_SUBMIT",
+ ENTERPRISE$FORM_SUBMITTING = "ENTERPRISE$FORM_SUBMITTING",
+ ENTERPRISE$REQUEST_SUBMITTED_TITLE = "ENTERPRISE$REQUEST_SUBMITTED_TITLE",
+ ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION = "ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION",
+ ENTERPRISE$DONE_BUTTON = "ENTERPRISE$DONE_BUTTON",
+ COMMON$BACK = "COMMON$BACK",
+ MODAL$CLOSE_BUTTON_LABEL = "MODAL$CLOSE_BUTTON_LABEL",
}
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index fc2874d6fe..de6dcd44f1 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -18700,142 +18700,6 @@
"uk": "Я довіряю цій навичці з {{sources}} із секретами агента, визначеними в моєму обліковому записі.",
"ca": "Confio en aquesta habilitat de {{sources}} amb els secrets de l'agent definits al meu compte."
},
- "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": "Яка ваша роль?",
- "ca": "Quin és el vostre rol?"
- },
- "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": "Виберіть варіант, який найкраще вам підходить",
- "ca": "Seleccioneu l'opció que millor us descrigui"
- },
- "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": "Програмний інженер / розробник",
- "ca": "Enginyer/a de programari / desenvolupador/a"
- },
- "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": "Менеджер з розробки / технічний лідер",
- "ca": "Responsable d'enginyeria / líder tècnic/a"
- },
- "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 / засновник",
- "ca": "CTO / fundador/a"
- },
- "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": "Роль продукту або операцій",
- "ca": "Rol de producte o operacions"
- },
- "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": "Студент / хобіст",
- "ca": "Estudiant / aficionat/da"
- },
- "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": "Інше",
- "ca": "Altre"
- },
"HOOKS_MODAL$TITLE": {
"en": "Available Hooks",
"ja": "利用可能なフック",
@@ -20642,5 +20506,600 @@
"uk": "Дізнатися більше"
,
"ca": "Més informació"
+ },
+ "ENTERPRISE$GET_OPENHANDS_TITLE": {
+ "en": "Get OpenHands for Enterprise",
+ "ja": "OpenHands Enterpriseを入手",
+ "zh-CN": "获取 OpenHands 企业版",
+ "zh-TW": "取得 OpenHands 企業版",
+ "ko-KR": "OpenHands 엔터프라이즈 시작하기",
+ "no": "Få OpenHands for bedrifter",
+ "ar": "احصل على OpenHands للمؤسسات",
+ "de": "OpenHands für Unternehmen",
+ "fr": "Obtenez OpenHands pour les entreprises",
+ "it": "Ottieni OpenHands per le aziende",
+ "pt": "Obtenha o OpenHands para empresas",
+ "es": "Obtener OpenHands para empresas",
+ "ca": "Obteniu OpenHands per a empreses",
+ "tr": "Kurumsal OpenHands Edinin",
+ "uk": "Отримайте OpenHands для підприємств"
+ },
+ "ENTERPRISE$GET_OPENHANDS_SUBTITLE": {
+ "en": "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before.",
+ "ja": "クラウドを使用すると、どこからでもOpenHandsにアクセスし、チームとこれまでにない方法で連携できます。",
+ "zh-CN": "云端让您可以随时随地访问 OpenHands,并以前所未有的方式与团队协作。",
+ "zh-TW": "雲端讓您可以隨時隨地存取 OpenHands,並以前所未有的方式與團隊協作。",
+ "ko-KR": "클라우드를 통해 어디서나 OpenHands에 접속하고 팀과 원활하게 협업하세요.",
+ "no": "Skyen lar deg få tilgang til OpenHands hvor som helst og koordinere med teamet ditt som aldri før.",
+ "ar": "يتيح لك السحاب الوصول إلى OpenHands من أي مكان والتنسيق مع فريقك بشكل غير مسبوق.",
+ "de": "Mit der Cloud können Sie von überall auf OpenHands zugreifen und sich wie nie zuvor mit Ihrem Team abstimmen.",
+ "fr": "Le cloud vous permet d'accéder à OpenHands n'importe où et de coordonner avec votre équipe comme jamais auparavant.",
+ "it": "Il cloud ti permette di accedere a OpenHands ovunque e coordinare con il tuo team come mai prima d'ora.",
+ "pt": "A nuvem permite que você acesse o OpenHands de qualquer lugar e coordene com sua equipe como nunca antes.",
+ "es": "La nube le permite acceder a OpenHands desde cualquier lugar y coordinar con su equipo como nunca antes.",
+ "ca": "El núvol us permet accedir a OpenHands des de qualsevol lloc i coordinar-vos amb el vostre equip com mai abans.",
+ "tr": "Bulut, OpenHands'e her yerden erişmenizi ve ekibinizle daha önce hiç olmadığı gibi koordinasyon sağlamanızı mümkün kılar.",
+ "uk": "Хмара дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше."
+ },
+ "ENTERPRISE$SAAS_TITLE": {
+ "en": "Enterprise SaaS",
+ "ja": "エンタープライズSaaS",
+ "zh-CN": "企业 SaaS",
+ "zh-TW": "企業 SaaS",
+ "ko-KR": "엔터프라이즈 SaaS",
+ "no": "Enterprise SaaS",
+ "ar": "SaaS للمؤسسات",
+ "de": "Enterprise SaaS",
+ "fr": "SaaS Entreprise",
+ "it": "SaaS Enterprise",
+ "pt": "SaaS Empresarial",
+ "es": "SaaS Empresarial",
+ "ca": "SaaS Empresarial",
+ "tr": "Kurumsal SaaS",
+ "uk": "Корпоративний SaaS"
+ },
+ "ENTERPRISE$SAAS_DESCRIPTION": {
+ "en": "Access OpenHands in the cloud. No setup required—start coding with your team from anywhere.",
+ "ja": "クラウドでOpenHandsにアクセス。セットアップ不要—どこからでもチームとコーディングを開始できます。",
+ "zh-CN": "在云端访问 OpenHands。无需设置——随时随地与团队开始编码。",
+ "zh-TW": "在雲端存取 OpenHands。無需設定——隨時隨地與團隊開始編碼。",
+ "ko-KR": "클라우드에서 OpenHands에 접속하세요. 설정 없이 어디서나 팀과 코딩을 시작하세요.",
+ "no": "Få tilgang til OpenHands i skyen. Ingen oppsett nødvendig—begynn å kode med teamet ditt fra hvor som helst.",
+ "ar": "الوصول إلى OpenHands في السحاب. لا حاجة للإعداد—ابدأ البرمجة مع فريقك من أي مكان.",
+ "de": "Greifen Sie auf OpenHands in der Cloud zu. Keine Einrichtung erforderlich—beginnen Sie sofort mit Ihrem Team zu programmieren.",
+ "fr": "Accédez à OpenHands dans le cloud. Aucune configuration requise—commencez à coder avec votre équipe de n'importe où.",
+ "it": "Accedi a OpenHands nel cloud. Nessuna configurazione richiesta—inizia a programmare con il tuo team da ovunque.",
+ "pt": "Acesse o OpenHands na nuvem. Sem configuração necessária—comece a programar com sua equipe de qualquer lugar.",
+ "es": "Acceda a OpenHands en la nube. Sin configuración requerida—comience a programar con su equipo desde cualquier lugar.",
+ "ca": "Accediu a OpenHands al núvol. Sense configuració necessària—comenceu a programar amb el vostre equip des de qualsevol lloc.",
+ "tr": "OpenHands'e bulutta erişin. Kurulum gerekmez—ekibinizle her yerden kodlamaya başlayın.",
+ "uk": "Отримайте доступ до OpenHands у хмарі. Налаштування не потрібне—починайте кодувати з командою з будь-якого місця."
+ },
+ "ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE": {
+ "en": "No infrastructure to manage",
+ "ja": "管理するインフラストラクチャなし",
+ "zh-CN": "无需管理基础设施",
+ "zh-TW": "無需管理基礎設施",
+ "ko-KR": "인프라 관리 불필요",
+ "no": "Ingen infrastruktur å administrere",
+ "ar": "لا حاجة لإدارة البنية التحتية",
+ "de": "Keine Infrastruktur zu verwalten",
+ "fr": "Aucune infrastructure à gérer",
+ "it": "Nessuna infrastruttura da gestire",
+ "pt": "Sem infraestrutura para gerenciar",
+ "es": "Sin infraestructura que gestionar",
+ "ca": "Sense infraestructura per gestionar",
+ "tr": "Yönetilecek altyapı yok",
+ "uk": "Не потрібно керувати інфраструктурою"
+ },
+ "ENTERPRISE$SAAS_FEATURE_SSO": {
+ "en": "SSO and team management",
+ "ja": "SSOとチーム管理",
+ "zh-CN": "SSO 和团队管理",
+ "zh-TW": "SSO 和團隊管理",
+ "ko-KR": "SSO 및 팀 관리",
+ "no": "SSO og teamadministrasjon",
+ "ar": "تسجيل دخول موحد وإدارة الفريق",
+ "de": "SSO und Teamverwaltung",
+ "fr": "SSO et gestion d'équipe",
+ "it": "SSO e gestione del team",
+ "pt": "SSO e gerenciamento de equipe",
+ "es": "SSO y gestión de equipos",
+ "ca": "SSO i gestió d'equips",
+ "tr": "SSO ve ekip yönetimi",
+ "uk": "SSO та управління командою"
+ },
+ "ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE": {
+ "en": "Access from any device",
+ "ja": "どのデバイスからでもアクセス",
+ "zh-CN": "从任何设备访问",
+ "zh-TW": "從任何裝置存取",
+ "ko-KR": "모든 기기에서 접속",
+ "no": "Tilgang fra enhver enhet",
+ "ar": "الوصول من أي جهاز",
+ "de": "Zugriff von jedem Gerät",
+ "fr": "Accès depuis n'importe quel appareil",
+ "it": "Accesso da qualsiasi dispositivo",
+ "pt": "Acesso de qualquer dispositivo",
+ "es": "Acceso desde cualquier dispositivo",
+ "ca": "Accés des de qualsevol dispositiu",
+ "tr": "Herhangi bir cihazdan erişim",
+ "uk": "Доступ з будь-якого пристрою"
+ },
+ "ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES": {
+ "en": "Automatic updates and security",
+ "ja": "自動更新とセキュリティ",
+ "zh-CN": "自动更新和安全",
+ "zh-TW": "自動更新和安全",
+ "ko-KR": "자동 업데이트 및 보안",
+ "no": "Automatiske oppdateringer og sikkerhet",
+ "ar": "تحديثات تلقائية وأمان",
+ "de": "Automatische Updates und Sicherheit",
+ "fr": "Mises à jour automatiques et sécurité",
+ "it": "Aggiornamenti automatici e sicurezza",
+ "pt": "Atualizações automáticas e segurança",
+ "es": "Actualizaciones automáticas y seguridad",
+ "ca": "Actualitzacions automàtiques i seguretat",
+ "tr": "Otomatik güncellemeler ve güvenlik",
+ "uk": "Автоматичні оновлення та безпека"
+ },
+ "ENTERPRISE$SELF_HOSTED_TITLE": {
+ "en": "Self-hosted",
+ "ja": "セルフホスト",
+ "zh-CN": "自托管",
+ "zh-TW": "自託管",
+ "ko-KR": "셀프 호스팅",
+ "no": "Selvhosted",
+ "ar": "استضافة ذاتية",
+ "de": "Selbst gehostet",
+ "fr": "Auto-hébergé",
+ "it": "Self-hosted",
+ "pt": "Auto-hospedado",
+ "es": "Autoalojado",
+ "ca": "Allotjament propi",
+ "tr": "Kendi sunucunuzda",
+ "uk": "Самостійний хостинг"
+ },
+ "ENTERPRISE$SELF_HOSTED_DESCRIPTION": {
+ "en": "Deploy OpenHands on your own infrastructure. Full control over your data, compliance, and security. Ideal for enterprises that require on-premises or private cloud deployment.",
+ "ja": "独自のインフラストラクチャにOpenHandsをデプロイ。データ、コンプライアンス、セキュリティを完全にコントロール。オンプレミスまたはプライベートクラウドの展開を必要とする企業に最適です。",
+ "zh-CN": "在您自己的基础设施上部署 OpenHands。完全控制您的数据、合规性和安全性。适合需要本地或私有云部署的企业。",
+ "zh-TW": "在您自己的基礎設施上部署 OpenHands。完全控制您的資料、合規性和安全性。適合需要本地或私有雲部署的企業。",
+ "ko-KR": "자체 인프라에 OpenHands를 배포하세요. 데이터, 규정 준수 및 보안을 완벽하게 제어합니다. 온프레미스 또는 프라이빗 클라우드 배포가 필요한 기업에 적합합니다.",
+ "no": "Distribuer OpenHands på din egen infrastruktur. Full kontroll over dine data, samsvar og sikkerhet. Ideelt for bedrifter som krever lokal eller privat sky-distribusjon.",
+ "ar": "انشر OpenHands على البنية التحتية الخاصة بك. تحكم كامل في بياناتك والامتثال والأمان. مثالي للمؤسسات التي تتطلب نشرًا محليًا أو سحابة خاصة.",
+ "de": "Stellen Sie OpenHands auf Ihrer eigenen Infrastruktur bereit. Volle Kontrolle über Ihre Daten, Compliance und Sicherheit. Ideal für Unternehmen, die eine lokale oder private Cloud-Bereitstellung benötigen.",
+ "fr": "Déployez OpenHands sur votre propre infrastructure. Contrôle total sur vos données, la conformité et la sécurité. Idéal pour les entreprises nécessitant un déploiement sur site ou en cloud privé.",
+ "it": "Distribuisci OpenHands sulla tua infrastruttura. Controllo completo sui tuoi dati, conformità e sicurezza. Ideale per le aziende che richiedono un deployment on-premises o su cloud privato.",
+ "pt": "Implante o OpenHands em sua própria infraestrutura. Controle total sobre seus dados, conformidade e segurança. Ideal para empresas que necessitam de implantação local ou em nuvem privada.",
+ "es": "Implemente OpenHands en su propia infraestructura. Control total sobre sus datos, cumplimiento y seguridad. Ideal para empresas que requieren implementación local o en nube privada.",
+ "ca": "Desplegueu OpenHands a la vostra pròpia infraestructura. Control total sobre les vostres dades, compliment i seguretat. Ideal per a empreses que requereixen desplegament local o en núvol privat.",
+ "tr": "OpenHands'i kendi altyapınızda dağıtın. Verileriniz, uyumluluk ve güvenlik üzerinde tam kontrol. Şirket içi veya özel bulut dağıtımı gerektiren işletmeler için idealdir.",
+ "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над вашими даними, відповідністю та безпекою. Ідеально для підприємств, які потребують локального або приватного хмарного розгортання."
+ },
+ "ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION": {
+ "en": "Deploy OpenHands on your own infrastructure. Full control over data, compliance, and security.",
+ "ja": "独自のインフラストラクチャにOpenHandsをデプロイ。データ、コンプライアンス、セキュリティを完全にコントロール。",
+ "zh-CN": "在您自己的基础设施上部署 OpenHands。完全控制数据、合规性和安全性。",
+ "zh-TW": "在您自己的基礎設施上部署 OpenHands。完全控制資料、合規性和安全性。",
+ "ko-KR": "자체 인프라에 OpenHands를 배포하세요. 데이터, 규정 준수 및 보안을 완벽하게 제어합니다.",
+ "no": "Distribuer OpenHands på din egen infrastruktur. Full kontroll over data, samsvar og sikkerhet.",
+ "ar": "انشر OpenHands على البنية التحتية الخاصة بك. تحكم كامل في البيانات والامتثال والأمان.",
+ "de": "Stellen Sie OpenHands auf Ihrer eigenen Infrastruktur bereit. Volle Kontrolle über Daten, Compliance und Sicherheit.",
+ "fr": "Déployez OpenHands sur votre propre infrastructure. Contrôle total sur les données, la conformité et la sécurité.",
+ "it": "Distribuisci OpenHands sulla tua infrastruttura. Controllo completo su dati, conformità e sicurezza.",
+ "pt": "Implante o OpenHands em sua própria infraestrutura. Controle total sobre dados, conformidade e segurança.",
+ "es": "Implemente OpenHands en su propia infraestructura. Control total sobre datos, cumplimiento y seguridad.",
+ "ca": "Desplegueu OpenHands a la vostra pròpia infraestructura. Control total sobre dades, compliment i seguretat.",
+ "tr": "OpenHands'i kendi altyapınızda dağıtın. Veriler, uyumluluk ve güvenlik üzerinde tam kontrol.",
+ "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою."
+ },
+ "ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES": {
+ "en": "On-premises or private cloud",
+ "ja": "オンプレミスまたはプライベートクラウド",
+ "zh-CN": "本地或私有云",
+ "zh-TW": "本地或私有雲",
+ "ko-KR": "온프레미스 또는 프라이빗 클라우드",
+ "no": "Lokalt eller privat sky",
+ "ar": "محلي أو سحابة خاصة",
+ "de": "Lokal oder private Cloud",
+ "fr": "Sur site ou cloud privé",
+ "it": "On-premises o cloud privato",
+ "pt": "Local ou nuvem privada",
+ "es": "Local o nube privada",
+ "ca": "Local o núvol privat",
+ "tr": "Yerel veya özel bulut",
+ "uk": "Локально або приватна хмара"
+ },
+ "ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL": {
+ "en": "Full data control",
+ "ja": "完全なデータ制御",
+ "zh-CN": "完全数据控制",
+ "zh-TW": "完全資料控制",
+ "ko-KR": "완전한 데이터 제어",
+ "no": "Full datakontroll",
+ "ar": "تحكم كامل في البيانات",
+ "de": "Vollständige Datenkontrolle",
+ "fr": "Contrôle total des données",
+ "it": "Controllo completo dei dati",
+ "pt": "Controle total de dados",
+ "es": "Control total de datos",
+ "ca": "Control total de dades",
+ "tr": "Tam veri kontrolü",
+ "uk": "Повний контроль даних"
+ },
+ "ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE": {
+ "en": "Custom compliance requirements",
+ "ja": "カスタムコンプライアンス要件",
+ "zh-CN": "自定义合规要求",
+ "zh-TW": "自訂合規要求",
+ "ko-KR": "맞춤 규정 준수 요구사항",
+ "no": "Tilpassede samsvarskrav",
+ "ar": "متطلبات امتثال مخصصة",
+ "de": "Individuelle Compliance-Anforderungen",
+ "fr": "Exigences de conformité personnalisées",
+ "it": "Requisiti di conformità personalizzati",
+ "pt": "Requisitos de conformidade personalizados",
+ "es": "Requisitos de cumplimiento personalizados",
+ "ca": "Requisits de compliment personalitzats",
+ "tr": "Özel uyumluluk gereksinimleri",
+ "uk": "Індивідуальні вимоги відповідності"
+ },
+ "ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT": {
+ "en": "Dedicated support options",
+ "ja": "専用サポートオプション",
+ "zh-CN": "专属支持选项",
+ "zh-TW": "專屬支援選項",
+ "ko-KR": "전담 지원 옵션",
+ "no": "Dedikerte støttealternativer",
+ "ar": "خيارات دعم مخصصة",
+ "de": "Dedizierte Support-Optionen",
+ "fr": "Options de support dédiées",
+ "it": "Opzioni di supporto dedicate",
+ "pt": "Opções de suporte dedicado",
+ "es": "Opciones de soporte dedicado",
+ "ca": "Opcions de suport dedicat",
+ "tr": "Özel destek seçenekleri",
+ "uk": "Виділені варіанти підтримки"
+ },
+ "ENTERPRISE$FORM_SAAS_TITLE": {
+ "en": "Learn more about Enterprise SaaS",
+ "ja": "Enterprise SaaSについて詳しく",
+ "zh-CN": "了解更多企业SaaS",
+ "zh-TW": "了解更多企業SaaS",
+ "ko-KR": "Enterprise SaaS 자세히 알아보기",
+ "no": "Lær mer om Enterprise SaaS",
+ "ar": "تعرف على المزيد حول Enterprise SaaS",
+ "de": "Erfahren Sie mehr über Enterprise SaaS",
+ "fr": "En savoir plus sur Enterprise SaaS",
+ "it": "Scopri di più su Enterprise SaaS",
+ "pt": "Saiba mais sobre Enterprise SaaS",
+ "es": "Conozca más sobre Enterprise SaaS",
+ "ca": "Més informació sobre Enterprise SaaS",
+ "tr": "Enterprise SaaS hakkında daha fazla bilgi edinin",
+ "uk": "Дізнайтеся більше про Enterprise SaaS"
+ },
+ "ENTERPRISE$FORM_SAAS_SUBTITLE": {
+ "en": "Tell us about your team and we'll help you get started.",
+ "ja": "チームについて教えてください。開始をお手伝いします。",
+ "zh-CN": "告诉我们您的团队情况,我们将帮助您开始使用。",
+ "zh-TW": "告訴我們您的團隊情況,我們將幫助您開始使用。",
+ "ko-KR": "팀에 대해 알려주시면 시작을 도와드리겠습니다.",
+ "no": "Fortell oss om teamet ditt, så hjelper vi deg i gang.",
+ "ar": "أخبرنا عن فريقك وسنساعدك على البدء.",
+ "de": "Erzählen Sie uns von Ihrem Team und wir helfen Ihnen beim Einstieg.",
+ "fr": "Parlez-nous de votre équipe et nous vous aiderons à démarrer.",
+ "it": "Raccontaci del tuo team e ti aiuteremo a iniziare.",
+ "pt": "Conte-nos sobre sua equipe e ajudaremos você a começar.",
+ "es": "Cuéntenos sobre su equipo y le ayudaremos a comenzar.",
+ "ca": "Expliqueu-nos sobre el vostre equip i us ajudarem a començar.",
+ "tr": "Bize ekibinizden bahsedin, başlamanıza yardımcı olalım.",
+ "uk": "Розкажіть нам про свою команду, і ми допоможемо вам почати."
+ },
+ "ENTERPRISE$FORM_SELF_HOSTED_TITLE": {
+ "en": "Learn more about Self-hosted",
+ "ja": "セルフホストについて詳しく",
+ "zh-CN": "了解更多自托管",
+ "zh-TW": "了解更多自託管",
+ "ko-KR": "셀프 호스팅 자세히 알아보기",
+ "no": "Lær mer om selvhostet",
+ "ar": "تعرف على المزيد حول الاستضافة الذاتية",
+ "de": "Erfahren Sie mehr über Self-hosted",
+ "fr": "En savoir plus sur l'auto-hébergement",
+ "it": "Scopri di più su Self-hosted",
+ "pt": "Saiba mais sobre auto-hospedagem",
+ "es": "Conozca más sobre auto-alojamiento",
+ "ca": "Més informació sobre auto-allotjament",
+ "tr": "Self-hosted hakkında daha fazla bilgi edinin",
+ "uk": "Дізнайтеся більше про самостійний хостинг"
+ },
+ "ENTERPRISE$FORM_SELF_HOSTED_SUBTITLE": {
+ "en": "Tell us about your needs and we'll be in touch.",
+ "ja": "ご要望をお聞かせください。ご連絡いたします。",
+ "zh-CN": "告诉我们您的需求,我们会与您联系。",
+ "zh-TW": "告訴我們您的需求,我們會與您聯繫。",
+ "ko-KR": "귀하의 요구 사항을 알려주시면 연락드리겠습니다.",
+ "no": "Fortell oss om dine behov, så tar vi kontakt.",
+ "ar": "أخبرنا عن احتياجاتك وسنتواصل معك.",
+ "de": "Erzählen Sie uns von Ihren Anforderungen und wir melden uns.",
+ "fr": "Parlez-nous de vos besoins et nous vous contacterons.",
+ "it": "Raccontaci le tue esigenze e ti contatteremo.",
+ "pt": "Conte-nos sobre suas necessidades e entraremos em contato.",
+ "es": "Cuéntenos sobre sus necesidades y nos pondremos en contacto.",
+ "ca": "Expliqueu-nos les vostres necessitats i us contactarem.",
+ "tr": "Bize ihtiyaçlarınızı anlatın, sizinle iletişime geçeceğiz.",
+ "uk": "Розкажіть нам про ваші потреби, і ми зв'яжемося з вами."
+ },
+ "ENTERPRISE$FORM_NAME_LABEL": {
+ "en": "Name",
+ "ja": "名前",
+ "zh-CN": "姓名",
+ "zh-TW": "姓名",
+ "ko-KR": "이름",
+ "no": "Navn",
+ "ar": "الاسم",
+ "de": "Name",
+ "fr": "Nom",
+ "it": "Nome",
+ "pt": "Nome",
+ "es": "Nombre",
+ "ca": "Nom",
+ "tr": "Ad",
+ "uk": "Ім'я"
+ },
+ "ENTERPRISE$FORM_NAME_PLACEHOLDER": {
+ "en": "Your name",
+ "ja": "あなたの名前",
+ "zh-CN": "您的姓名",
+ "zh-TW": "您的姓名",
+ "ko-KR": "이름",
+ "no": "Ditt navn",
+ "ar": "اسمك",
+ "de": "Ihr Name",
+ "fr": "Votre nom",
+ "it": "Il tuo nome",
+ "pt": "Seu nome",
+ "es": "Su nombre",
+ "ca": "El vostre nom",
+ "tr": "Adınız",
+ "uk": "Ваше ім'я"
+ },
+ "ENTERPRISE$FORM_EMAIL_LABEL": {
+ "en": "Email address",
+ "ja": "メールアドレス",
+ "zh-CN": "电子邮件地址",
+ "zh-TW": "電子郵件地址",
+ "ko-KR": "이메일 주소",
+ "no": "E-postadresse",
+ "ar": "عنوان البريد الإلكتروني",
+ "de": "E-Mail-Adresse",
+ "fr": "Adresse e-mail",
+ "it": "Indirizzo email",
+ "pt": "Endereço de e-mail",
+ "es": "Dirección de correo electrónico",
+ "ca": "Adreça de correu electrònic",
+ "tr": "E-posta adresi",
+ "uk": "Електронна адреса"
+ },
+ "ENTERPRISE$FORM_EMAIL_PLACEHOLDER": {
+ "en": "name@company.com",
+ "ja": "name@company.com",
+ "zh-CN": "name@company.com",
+ "zh-TW": "name@company.com",
+ "ko-KR": "name@company.com",
+ "no": "name@company.com",
+ "ar": "name@company.com",
+ "de": "name@company.com",
+ "fr": "nom@entreprise.com",
+ "it": "nome@azienda.com",
+ "pt": "nome@empresa.com",
+ "es": "nombre@empresa.com",
+ "ca": "nom@empresa.com",
+ "tr": "isim@sirket.com",
+ "uk": "ім'я@компанія.com"
+ },
+ "ENTERPRISE$FORM_COMPANY_LABEL": {
+ "en": "Company name",
+ "ja": "会社名",
+ "zh-CN": "公司名称",
+ "zh-TW": "公司名稱",
+ "ko-KR": "회사명",
+ "no": "Selskapsnavn",
+ "ar": "اسم الشركة",
+ "de": "Firmenname",
+ "fr": "Nom de l'entreprise",
+ "it": "Nome azienda",
+ "pt": "Nome da empresa",
+ "es": "Nombre de la empresa",
+ "ca": "Nom de l'empresa",
+ "tr": "Şirket adı",
+ "uk": "Назва компанії"
+ },
+ "ENTERPRISE$FORM_COMPANY_PLACEHOLDER": {
+ "en": "Your company",
+ "ja": "あなたの会社",
+ "zh-CN": "您的公司",
+ "zh-TW": "您的公司",
+ "ko-KR": "회사명",
+ "no": "Ditt selskap",
+ "ar": "شركتك",
+ "de": "Ihr Unternehmen",
+ "fr": "Votre entreprise",
+ "it": "La tua azienda",
+ "pt": "Sua empresa",
+ "es": "Su empresa",
+ "ca": "La vostra empresa",
+ "tr": "Şirketiniz",
+ "uk": "Ваша компанія"
+ },
+ "ENTERPRISE$FORM_MESSAGE_LABEL": {
+ "en": "Custom message",
+ "ja": "カスタムメッセージ",
+ "zh-CN": "自定义消息",
+ "zh-TW": "自訂訊息",
+ "ko-KR": "맞춤 메시지",
+ "no": "Tilpasset melding",
+ "ar": "رسالة مخصصة",
+ "de": "Benutzerdefinierte Nachricht",
+ "fr": "Message personnalisé",
+ "it": "Messaggio personalizzato",
+ "pt": "Mensagem personalizada",
+ "es": "Mensaje personalizado",
+ "ca": "Missatge personalitzat",
+ "tr": "Özel mesaj",
+ "uk": "Користувацьке повідомлення"
+ },
+ "ENTERPRISE$FORM_MESSAGE_SAAS_PLACEHOLDER": {
+ "en": "Tell us about your team and use case...",
+ "ja": "チームと使用目的について教えてください...",
+ "zh-CN": "告诉我们您的团队和用例...",
+ "zh-TW": "告訴我們您的團隊和用例...",
+ "ko-KR": "팀과 사용 사례에 대해 알려주세요...",
+ "no": "Fortell oss om teamet ditt og brukstilfelle...",
+ "ar": "أخبرنا عن فريقك وحالة الاستخدام...",
+ "de": "Erzählen Sie uns von Ihrem Team und Anwendungsfall...",
+ "fr": "Parlez-nous de votre équipe et cas d'utilisation...",
+ "it": "Raccontaci del tuo team e caso d'uso...",
+ "pt": "Conte-nos sobre sua equipe e caso de uso...",
+ "es": "Cuéntenos sobre su equipo y caso de uso...",
+ "ca": "Expliqueu-nos sobre el vostre equip i cas d'ús...",
+ "tr": "Bize ekibiniz ve kullanım durumunuz hakkında bilgi verin...",
+ "uk": "Розкажіть нам про свою команду та варіант використання..."
+ },
+ "ENTERPRISE$FORM_MESSAGE_SELF_HOSTED_PLACEHOLDER": {
+ "en": "Tell us about your deployment needs...",
+ "ja": "デプロイメントのニーズについて教えてください...",
+ "zh-CN": "告诉我们您的部署需求...",
+ "zh-TW": "告訴我們您的部署需求...",
+ "ko-KR": "배포 요구 사항에 대해 알려주세요...",
+ "no": "Fortell oss om dine distribusjonsbehov...",
+ "ar": "أخبرنا عن احتياجات النشر الخاصة بك...",
+ "de": "Erzählen Sie uns von Ihren Bereitstellungsanforderungen...",
+ "fr": "Parlez-nous de vos besoins de déploiement...",
+ "it": "Raccontaci le tue esigenze di distribuzione...",
+ "pt": "Conte-nos sobre suas necessidades de implantação...",
+ "es": "Cuéntenos sobre sus necesidades de implementación...",
+ "ca": "Expliqueu-nos les vostres necessitats de desplegament...",
+ "tr": "Bize dağıtım ihtiyaçlarınızı anlatın...",
+ "uk": "Розкажіть нам про ваші потреби у розгортанні..."
+ },
+ "ENTERPRISE$FORM_SUBMIT": {
+ "en": "Submit",
+ "ja": "送信",
+ "zh-CN": "提交",
+ "zh-TW": "提交",
+ "ko-KR": "제출",
+ "no": "Send inn",
+ "ar": "إرسال",
+ "de": "Absenden",
+ "fr": "Envoyer",
+ "it": "Invia",
+ "pt": "Enviar",
+ "es": "Enviar",
+ "ca": "Enviar",
+ "tr": "Gönder",
+ "uk": "Надіслати"
+ },
+ "ENTERPRISE$FORM_SUBMITTING": {
+ "en": "Submitting...",
+ "ja": "送信中...",
+ "zh-CN": "提交中...",
+ "zh-TW": "提交中...",
+ "ko-KR": "제출 중...",
+ "no": "Sender inn...",
+ "ar": "جارٍ الإرسال...",
+ "de": "Wird gesendet...",
+ "fr": "Envoi en cours...",
+ "it": "Invio in corso...",
+ "pt": "Enviando...",
+ "es": "Enviando...",
+ "ca": "Enviant...",
+ "tr": "Gönderiliyor...",
+ "uk": "Надсилається..."
+ },
+ "ENTERPRISE$REQUEST_SUBMITTED_TITLE": {
+ "en": "Request submitted",
+ "ja": "リクエストが送信されました",
+ "zh-CN": "请求已提交",
+ "zh-TW": "請求已提交",
+ "ko-KR": "요청이 제출되었습니다",
+ "no": "Forespørsel sendt",
+ "ar": "تم إرسال الطلب",
+ "de": "Anfrage gesendet",
+ "fr": "Demande soumise",
+ "it": "Richiesta inviata",
+ "pt": "Solicitação enviada",
+ "es": "Solicitud enviada",
+ "ca": "Sol·licitud enviada",
+ "tr": "İstek gönderildi",
+ "uk": "Запит надіслано"
+ },
+ "ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION": {
+ "en": "Your request has been submitted. We will follow up with next steps shortly.",
+ "ja": "リクエストが送信されました。次のステップについて、まもなくご連絡いたします。",
+ "zh-CN": "您的请求已提交。我们将很快与您联系后续步骤。",
+ "zh-TW": "您的請求已提交。我們將很快與您聯繫後續步驟。",
+ "ko-KR": "요청이 제출되었습니다. 곧 다음 단계에 대해 연락드리겠습니다.",
+ "no": "Forespørselen din er sendt. Vi følger opp med neste trinn snart.",
+ "ar": "تم إرسال طلبك. سنتواصل معك قريباً بشأن الخطوات التالية.",
+ "de": "Ihre Anfrage wurde gesendet. Wir werden uns in Kürze mit den nächsten Schritten bei Ihnen melden.",
+ "fr": "Votre demande a été soumise. Nous vous contacterons prochainement pour les prochaines étapes.",
+ "it": "La tua richiesta è stata inviata. Ti contatteremo a breve con i prossimi passi.",
+ "pt": "Sua solicitação foi enviada. Entraremos em contato em breve com os próximos passos.",
+ "es": "Su solicitud ha sido enviada. Nos pondremos en contacto pronto con los próximos pasos.",
+ "ca": "La seva sol·licitud s'ha enviat. Ens posarem en contacte aviat amb els propers passos.",
+ "tr": "İsteğiniz gönderildi. En kısa sürede sonraki adımlar hakkında sizinle iletişime geçeceğiz.",
+ "uk": "Ваш запит надіслано. Ми зв'яжемося з вами найближчим часом щодо наступних кроків."
+ },
+ "ENTERPRISE$DONE_BUTTON": {
+ "en": "Done",
+ "ja": "完了",
+ "zh-CN": "完成",
+ "zh-TW": "完成",
+ "ko-KR": "완료",
+ "no": "Ferdig",
+ "ar": "تم",
+ "de": "Fertig",
+ "fr": "Terminé",
+ "it": "Fatto",
+ "pt": "Concluído",
+ "es": "Hecho",
+ "ca": "Fet",
+ "tr": "Tamam",
+ "uk": "Готово"
+ },
+ "COMMON$BACK": {
+ "en": "Back",
+ "ja": "戻る",
+ "zh-CN": "返回",
+ "zh-TW": "返回",
+ "ko-KR": "뒤로",
+ "no": "Tilbake",
+ "ar": "رجوع",
+ "de": "Zurück",
+ "fr": "Retour",
+ "it": "Indietro",
+ "pt": "Voltar",
+ "es": "Volver",
+ "ca": "Enrere",
+ "tr": "Geri",
+ "uk": "Назад"
+ },
+ "MODAL$CLOSE_BUTTON_LABEL": {
+ "en": "Close",
+ "ja": "閉じる",
+ "zh-CN": "关闭",
+ "zh-TW": "關閉",
+ "ko-KR": "닫기",
+ "no": "Lukk",
+ "ar": "إغلاق",
+ "de": "Schließen",
+ "fr": "Fermer",
+ "it": "Chiudi",
+ "pt": "Fechar",
+ "es": "Cerrar",
+ "ca": "Tancar",
+ "tr": "Kapat",
+ "uk": "Закрити"
}
}
diff --git a/frontend/src/icons/cloud-minimal.svg b/frontend/src/icons/cloud-minimal.svg
new file mode 100644
index 0000000000..65438b8099
--- /dev/null
+++ b/frontend/src/icons/cloud-minimal.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/icons/modal-close.svg b/frontend/src/icons/modal-close.svg
new file mode 100644
index 0000000000..0430d64411
--- /dev/null
+++ b/frontend/src/icons/modal-close.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index 5bdef1134e..a62f95e5c7 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -8,6 +8,7 @@ import {
export default [
route("login", "routes/login.tsx"),
route("onboarding", "routes/onboarding-form.tsx"),
+ route("information-request", "routes/information-request.tsx"),
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),
diff --git a/frontend/src/routes/information-request.tsx b/frontend/src/routes/information-request.tsx
new file mode 100644
index 0000000000..81f207890f
--- /dev/null
+++ b/frontend/src/routes/information-request.tsx
@@ -0,0 +1,181 @@
+import { useState, useEffect, useCallback } from "react";
+import { useTranslation } from "react-i18next";
+import { Link, redirect } from "react-router";
+import { I18nKey } from "#/i18n/declaration";
+import { Typography } from "#/ui/typography";
+import {
+ InformationRequestForm,
+ RequestType,
+} from "#/components/features/onboarding/information-request-form";
+import { EnterpriseCard } from "#/components/features/onboarding/enterprise-card";
+import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
+import CloudIcon from "#/icons/cloud-minimal.svg?react";
+import StackedIcon from "#/icons/stacked.svg?react";
+import {
+ EnterpriseFormData,
+ getEnterpriseFormData,
+ saveEnterpriseFormData,
+} from "#/utils/local-storage";
+import { cn } from "#/utils/utils";
+import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
+
+export const clientLoader = async () => {
+ if (!ENABLE_PROJ_USER_JOURNEY()) {
+ return redirect("/login");
+ }
+ return null;
+};
+
+const DEFAULT_FORM_DATA: EnterpriseFormData = {
+ name: "",
+ company: "",
+ email: "",
+ message: "",
+};
+
+export default function InformationRequest() {
+ const { t } = useTranslation();
+ const [selectedRequestType, setSelectedRequestType] =
+ useState(null);
+ const [saasFormData, setSaasFormData] =
+ useState(DEFAULT_FORM_DATA);
+ const [selfHostedFormData, setSelfHostedFormData] =
+ useState(DEFAULT_FORM_DATA);
+
+ // Load saved form data from localStorage on mount
+ useEffect(() => {
+ const savedSaasData = getEnterpriseFormData("saas");
+ if (savedSaasData) {
+ setSaasFormData(savedSaasData);
+ }
+
+ const savedSelfHostedData = getEnterpriseFormData("self-hosted");
+ if (savedSelfHostedData) {
+ setSelfHostedFormData(savedSelfHostedData);
+ }
+ }, []);
+
+ const handleLearnMore = (type: RequestType) => {
+ setSelectedRequestType(type);
+ };
+
+ const handleFormBack = () => {
+ setSelectedRequestType(null);
+ };
+
+ const handleFormDataChange = useCallback(
+ (data: EnterpriseFormData) => {
+ if (selectedRequestType === "saas") {
+ setSaasFormData(data);
+ saveEnterpriseFormData("saas", data);
+ } else if (selectedRequestType === "self-hosted") {
+ setSelfHostedFormData(data);
+ saveEnterpriseFormData("self-hosted", data);
+ }
+ },
+ [selectedRequestType],
+ );
+
+ const currentFormData =
+ selectedRequestType === "saas" ? saasFormData : selfHostedFormData;
+
+ const saasFeatures = [
+ t(I18nKey.ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE),
+ t(I18nKey.ENTERPRISE$SAAS_FEATURE_SSO),
+ t(I18nKey.ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE),
+ t(I18nKey.ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES),
+ ];
+
+ const selfHostedFeatures = [
+ t(I18nKey.ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES),
+ t(I18nKey.ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL),
+ t(I18nKey.ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE),
+ t(I18nKey.ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT),
+ ];
+
+ // Show form if a request type is selected
+ if (selectedRequestType) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Logo */}
+
+
+ {/* Header */}
+
+
+ {t(I18nKey.ENTERPRISE$GET_OPENHANDS_TITLE)}
+
+
+ {t(I18nKey.ENTERPRISE$GET_OPENHANDS_SUBTITLE)}
+
+
+
+ {/* Cards */}
+
+ }
+ title={t(I18nKey.ENTERPRISE$SAAS_TITLE)}
+ description={t(I18nKey.ENTERPRISE$SAAS_DESCRIPTION)}
+ features={saasFeatures}
+ onLearnMore={() => handleLearnMore("saas")}
+ learnMoreLabel={t(I18nKey.ENTERPRISE$LEARN_MORE)}
+ />
+ }
+ title={t(I18nKey.ENTERPRISE$SELF_HOSTED_TITLE)}
+ description={t(I18nKey.ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION)}
+ features={selfHostedFeatures}
+ onLearnMore={() => handleLearnMore("self-hosted")}
+ learnMoreLabel={t(I18nKey.ENTERPRISE$LEARN_MORE)}
+ />
+
+
+ {/* Back Link */}
+
+ {t(I18nKey.COMMON$BACK)}
+
+
+
+ );
+}
diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx
index e8766d9500..2227c09eb0 100644
--- a/frontend/src/routes/login.tsx
+++ b/frontend/src/routes/login.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { useNavigate, useSearchParams } from "react-router";
+import { useNavigate, useSearchParams, useLocation } from "react-router";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { useConfig } from "#/hooks/query/use-config";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
@@ -7,11 +7,18 @@ import { useEmailVerification } from "#/hooks/use-email-verification";
import { useInvitation } from "#/hooks/use-invitation";
import { LoginContent } from "#/components/features/auth/login-content";
import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal";
+import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal";
+
+interface LocationState {
+ showRequestSubmittedModal?: boolean;
+}
export default function LoginPage() {
const navigate = useNavigate();
+ const location = useLocation();
const [searchParams] = useSearchParams();
const returnTo = searchParams.get("returnTo") || "/";
+ const locationState = location.state as LocationState | null;
const config = useConfig();
const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();
@@ -32,6 +39,15 @@ export default function LoginPage() {
authUrl: config.data?.auth_url,
});
+ const [showRequestModal, setShowRequestModal] = React.useState(
+ () => locationState?.showRequestSubmittedModal ?? false,
+ );
+
+ const handleRequestModalClose = () => {
+ setShowRequestModal(false);
+ navigate(location.pathname, { replace: true, state: {} });
+ };
+
// Redirect OSS mode users to home
React.useEffect(() => {
if (!config.isLoading && config.data?.app_mode === "oss") {
@@ -94,6 +110,10 @@ export default function LoginPage() {
wasRateLimited={wasRateLimited}
/>
)}
+
+ {showRequestModal && (
+
+ )}
>
);
}
diff --git a/frontend/src/routes/onboarding-form.tsx b/frontend/src/routes/onboarding-form.tsx
index 6d5c092247..88e71f75aa 100644
--- a/frontend/src/routes/onboarding-form.tsx
+++ b/frontend/src/routes/onboarding-form.tsx
@@ -10,7 +10,6 @@ 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";
import { useConfig } from "#/hooks/query/use-config";
import {
ONBOARDING_FORM,
@@ -181,63 +180,61 @@ function OnboardingForm() {
const translatedInputFields = getTranslatedInputFields(currentStep, t);
return (
-
+
+
+
+
+
+
-
-
-
-
-
-
- {!isFirstStep && (
-
- {t(I18nKey.ONBOARDING$BACK_BUTTON)}
-
- )}
+ {!isFirstStep && (
- {t(
- isLastStep
- ? I18nKey.ONBOARDING$FINISH_BUTTON
- : I18nKey.ONBOARDING$NEXT_BUTTON,
- )}
+ {t(I18nKey.ONBOARDING$BACK_BUTTON)}
-
+ )}
+
+ {t(
+ isLastStep
+ ? I18nKey.ONBOARDING$FINISH_BUTTON
+ : I18nKey.ONBOARDING$NEXT_BUTTON,
+ )}
+
-
+
);
}
diff --git a/frontend/src/ui/card.tsx b/frontend/src/ui/card.tsx
index ea420c4296..758a03d6b7 100644
--- a/frontend/src/ui/card.tsx
+++ b/frontend/src/ui/card.tsx
@@ -9,9 +9,33 @@ const cardVariants = cva("flex", {
outlined: "relative bg-transparent border border-[#727987] rounded-xl",
dark: "relative bg-black border border-[#242424] rounded-2xl",
},
+ hover: {
+ none: "",
+ elevated: [
+ "transition-all duration-200",
+ "hover:bg-[linear-gradient(180deg,#0F0F0F_0%,#0A0A0A_100%)]",
+ "hover:border-t-[#242424CC]",
+ "hover:shadow-[0px_4px_6px_-4px_#0000001A,0px_10px_15px_-3px_#0000001A]",
+ "before:absolute before:inset-0 before:rounded-2xl before:opacity-0 before:transition-opacity before:duration-200",
+ "before:bg-[radial-gradient(98.4%_116.11%_at_50%_0%,rgba(255,255,255,0.08)_0%,rgba(0,0,0,0)_70%)]",
+ "hover:before:opacity-100",
+ "before:pointer-events-none",
+ ].join(" "),
+ },
+ gradient: {
+ none: "",
+ standard: [
+ "bg-[#0A0A0A80] border-t-[#24242499]",
+ "shadow-[0px_4px_6px_-4px_#0000001A,0px_10px_15px_-3px_#0000001A]",
+ "before:absolute before:inset-0 before:rounded-2xl before:pointer-events-none",
+ "before:bg-[radial-gradient(144.32%_106.6%_at_50%_0%,rgba(255,255,255,0.14)_0%,rgba(0,0,0,0)_55%)]",
+ ].join(" "),
+ },
},
defaultVariants: {
theme: "default",
+ hover: "none",
+ gradient: "none",
},
});
@@ -21,11 +45,18 @@ interface CardProps extends VariantProps {
testId?: string;
}
-export function Card({ children, className, testId, theme }: CardProps) {
+export function Card({
+ children,
+ className,
+ testId,
+ theme,
+ hover,
+ gradient,
+}: CardProps) {
return (
{children}
diff --git a/frontend/src/utils/local-storage.ts b/frontend/src/utils/local-storage.ts
index a942e6fab5..656f1dd2fc 100644
--- a/frontend/src/utils/local-storage.ts
+++ b/frontend/src/utils/local-storage.ts
@@ -1,6 +1,8 @@
// Local storage keys
export const LOCAL_STORAGE_KEYS = {
LOGIN_METHOD: "openhands_login_method",
+ ENTERPRISE_FORM_SAAS: "openhands_enterprise_form_saas",
+ ENTERPRISE_FORM_SELF_HOSTED: "openhands_enterprise_form_self_hosted",
};
// Login methods
@@ -59,3 +61,55 @@ export const setCTADismissed = (location: CTALocation): void => {
*/
export const isCTADismissed = (location: CTALocation): boolean =>
localStorage.getItem(getCTAKey(location)) === "true";
+
+// Enterprise form data types
+export type EnterpriseFormType = "saas" | "self-hosted";
+
+export interface EnterpriseFormData {
+ name: string;
+ company: string;
+ email: string;
+ message: string;
+}
+
+const getEnterpriseFormKey = (formType: EnterpriseFormType): string =>
+ formType === "saas"
+ ? LOCAL_STORAGE_KEYS.ENTERPRISE_FORM_SAAS
+ : LOCAL_STORAGE_KEYS.ENTERPRISE_FORM_SELF_HOSTED;
+
+/**
+ * Save enterprise form data to localStorage
+ * @param formType The type of form (saas or self-hosted)
+ * @param data The form data to save
+ */
+export const saveEnterpriseFormData = (
+ formType: EnterpriseFormType,
+ data: EnterpriseFormData,
+): void => {
+ localStorage.setItem(getEnterpriseFormKey(formType), JSON.stringify(data));
+};
+
+/**
+ * Get enterprise form data from localStorage
+ * @param formType The type of form (saas or self-hosted)
+ * @returns The saved form data or null if not found
+ */
+export const getEnterpriseFormData = (
+ formType: EnterpriseFormType,
+): EnterpriseFormData | null => {
+ const data = localStorage.getItem(getEnterpriseFormKey(formType));
+ if (!data) return null;
+ try {
+ return JSON.parse(data) as EnterpriseFormData;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Clear enterprise form data from localStorage
+ * @param formType The type of form (saas or self-hosted)
+ */
+export const clearEnterpriseFormData = (formType: EnterpriseFormType): void => {
+ localStorage.removeItem(getEnterpriseFormKey(formType));
+};