feat(frontend): lead capture form (#13496)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
HeyItsChloe
2026-03-24 13:41:35 -07:00
committed by GitHub
parent 9f47727da5
commit abdc58cd28
30 changed files with 2604 additions and 324 deletions

View File

@@ -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: () => <div data-testid="information-request-page" />,
},
]);
return render(<Stub initialEntries={["/"]} />);
};
it("should render enterprise CTA with title and description", () => {
render(<LoginCTA />);
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(<LoginCTA />);
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(<LoginCTA />);
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(<LoginCTA />);
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",
);
});
});

View File

@@ -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: <svg data-testid="test-icon" />,
title: "Test Title",
description: "Test description",
features: ["Feature 1", "Feature 2"],
learnMoreLabel: "Learn More",
onLearnMore: vi.fn(),
};
const renderWithRouter = (props = defaultProps) =>
render(
<MemoryRouter>
<EnterpriseCard {...props} />
</MemoryRouter>,
);
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");
});
});

View File

@@ -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(<FeatureList features={features} />);
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(<FeatureList features={features} />);
const bullets = screen.getAllByText("•");
expect(bullets).toHaveLength(2);
});
it("should render an empty list when no features provided", () => {
render(<FeatureList features={[]} />);
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(<FeatureList features={features} />);
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(2);
});
});

View File

@@ -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(<FormInput {...defaultProps} />);
expect(screen.getByTestId("form-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<FormInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<FormInput {...defaultProps} value="Hello World" />);
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(<FormInput {...defaultProps} onChange={mockOnChange} />);
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(<FormInput {...defaultProps} />);
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(<FormInput {...defaultProps} type="email" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("type", "email");
});
it("should render a textarea when rows prop is provided", () => {
render(<FormInput {...defaultProps} rows={4} />);
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea.tagName).toBe("TEXTAREA");
expect(textarea).toHaveAttribute("rows", "4");
});
it("should render placeholder text", () => {
render(<FormInput {...defaultProps} placeholder="Enter text here" />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("placeholder", "Enter text here");
});
it("should have aria-required attribute when required", () => {
render(<FormInput {...defaultProps} required />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-required", "true");
});
it("should have aria-label attribute", () => {
render(<FormInput {...defaultProps} />);
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(<FormInput {...defaultProps} required />);
const input = screen.getByTestId("form-input-test-input");
expect(input).toBeRequired();
});
it("should have required attribute on textarea when required", () => {
render(<FormInput {...defaultProps} rows={4} required />);
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea).toBeRequired();
});
it("should associate label with input via htmlFor", () => {
render(<FormInput {...defaultProps} />);
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(<FormInput {...defaultProps} required showError />);
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(<FormInput {...defaultProps} required showError={false} />);
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(<FormInput {...defaultProps} required showError value="filled" />);
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(<FormInput {...defaultProps} required={false} showError />);
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(<FormInput {...defaultProps} rows={4} required showError />);
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(
<FormInput {...defaultProps} type="email" value="invalid" showError />,
);
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(
<FormInput
{...defaultProps}
type="email"
value="test@example.com"
showError
/>,
);
const input = screen.getByTestId("form-input-test-input");
expect(input).toHaveAttribute("aria-invalid", "false");
});
});
});

View File

@@ -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<EnterpriseFormData>({ name: "", company: "", email: "", message: "" });
return <InformationRequestForm requestType={requestType} formData={formData} onFormDataChange={setFormData} onBack={mockOnBack} />;
}
describe("InformationRequestForm", () => {
const defaultProps = {
requestType: "saas" as RequestType,
};
beforeEach(() => {
vi.clearAllMocks();
mockOnBack.mockClear();
});
const renderWithRouter = (props = defaultProps) => {
const Stub = createRoutesStub([
{
path: "/",
Component: () => <StatefulForm {...props} />,
},
{
path: "/login",
Component: () => <div data-testid="login-page" />,
},
{
path: "/information-request",
Component: () => <div data-testid="information-request-page" />,
},
]);
return render(<Stub initialEntries={["/"]} />);
};
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();
});
});
});

View File

@@ -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(<RequestSubmittedModal {...defaultProps} />);
expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument();
});
it("should render the title", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"),
).toBeInTheDocument();
});
it("should render the description", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"),
).toBeInTheDocument();
});
it("should render the Done button", () => {
render(<RequestSubmittedModal {...defaultProps} />);
expect(
screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }),
).toBeInTheDocument();
});
it("should render the close button", () => {
render(<RequestSubmittedModal {...defaultProps} />);
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(<RequestSubmittedModal onClose={mockOnClose} />);
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(<RequestSubmittedModal onClose={mockOnClose} />);
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(<RequestSubmittedModal onClose={mockOnClose} />);
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(<RequestSubmittedModal onClose={mockOnClose} />);
// 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(<RequestSubmittedModal {...defaultProps} />);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute(
"aria-label",
"ENTERPRISE$REQUEST_SUBMITTED_TITLE",
);
});
});

View File

@@ -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(<StepInput {...defaultProps} />);
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<StepInput {...defaultProps} value="Hello World" />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveValue("Hello World");
});
it("should call onChange when user types", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "a");
expect(mockOnChange).toHaveBeenCalledWith("a");
});
it("should call onChange with the full input value on each keystroke", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "abc");
expect(mockOnChange).toHaveBeenCalledTimes(3);
expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
});
it("should use the id prop for data-testid", () => {
render(<StepInput {...defaultProps} id="org_name" />);
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
});
it("should render as a text input", () => {
render(<StepInput {...defaultProps} />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveAttribute("type", "text");
});
});

View File

@@ -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", () => {

View File

@@ -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(
<MemoryRouter>
<InformationRequest />
</MemoryRouter>,
);
};
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();
});
});

View File

@@ -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>Card Content</Card>);
expect(screen.getByText("Card Content")).toBeInTheDocument();
});
it("should render with testId", () => {
render(<Card testId="test-card">Content</Card>);
expect(screen.getByTestId("test-card")).toBeInTheDocument();
});
it("should apply custom className", () => {
render(
<Card testId="test-card" className="custom-class">
Content
</Card>,
);
expect(screen.getByTestId("test-card")).toHaveClass("custom-class");
});
describe("theme variants", () => {
it("should apply default theme styles", () => {
render(<Card testId="test-card">Content</Card>);
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(
<Card testId="test-card" theme="outlined">
Content
</Card>,
);
const card = screen.getByTestId("test-card");
expect(card).toHaveClass("bg-transparent");
expect(card).toHaveClass("border-[#727987]");
});
it("should apply dark theme styles", () => {
render(
<Card testId="test-card" theme="dark">
Content
</Card>,
);
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(
<Card testId="test-card" hover="none">
Content
</Card>,
);
const card = screen.getByTestId("test-card");
expect(card).not.toHaveClass("hover:bg-[linear-gradient");
});
it("should apply elevated hover styles", () => {
render(
<Card testId="test-card" hover="elevated">
Content
</Card>,
);
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(
<Card testId="test-card" gradient="none">
Content
</Card>,
);
const card = screen.getByTestId("test-card");
expect(card).not.toHaveClass("bg-[#0A0A0A80]");
});
it("should apply standard gradient styles", () => {
render(
<Card testId="test-card" gradient="standard">
Content
</Card>,
);
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(
<Card testId="test-card" theme="dark" gradient="standard">
Content
</Card>,
);
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(
<Card testId="test-card" theme="dark" hover="elevated">
Content
</Card>,
);
const card = screen.getByTestId("test-card");
expect(card).toHaveClass("rounded-2xl");
expect(card).toHaveClass("transition-all");
});
});
it("should have flex display by default", () => {
render(<Card testId="test-card">Content</Card>);
expect(screen.getByTestId("test-card")).toHaveClass("flex");
});
it("should have relative positioning", () => {
render(<Card testId="test-card">Content</Card>);
expect(screen.getByTestId("test-card")).toHaveClass("relative");
});
});

View File

@@ -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() {
</ul>
<div className={cn("h-10 flex justify-start")}>
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
<Link
to="/information-request"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#0a0a0a]",
"text-white hover:bg-white hover:text-black",
"font-semibold text-sm",
"transition-colors",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</Link>
</div>
</div>
</Card>

View File

@@ -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 (
<Card
theme="dark"
hover="elevated"
className={cn(
"w-full md:w-[438px] md:min-h-[371.5px] flex-col p-6 gap-4",
)}
>
<div className={cn("w-10 h-10")}>{icon}</div>
<Typography.H3 className={cn("text-lg font-semibold text-white")}>
{title}
</Typography.H3>
<Typography.Text className={cn("text-[#8C8C8C]")}>
{description}
</Typography.Text>
<FeatureList features={features} />
<Link
to="/information-request"
onClick={onLearnMore}
aria-label={`${learnMoreLabel} ${title}`}
className={cn(
"mt-2 w-fit px-6 py-2.5 text-sm rounded-sm",
"bg-[#050505] text-white border border-[#242424]",
"hover:bg-white hover:text-black transition-colors",
)}
>
{learnMoreLabel}
</Link>
</Card>
);
}

View File

@@ -0,0 +1,20 @@
import { Typography } from "#/ui/typography";
interface FeatureListProps {
features: string[];
}
export function FeatureList({ features }: FeatureListProps) {
return (
<ul className="flex flex-col gap-1">
{features.map((feature, index) => (
<li key={`feature-${index}`} className="flex items-center gap-2">
<Typography.Text className="text-[#8C8C8C]"></Typography.Text>
<Typography.Text className="text-[#8C8C8C]">
{feature}
</Typography.Text>
</li>
))}
</ul>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-1.5 w-full">
<label
htmlFor={inputId}
className="text-sm font-medium leading-5 text-[#FAFAFA] cursor-pointer"
>
{label}
</label>
{rows ? (
<textarea
id={inputId}
data-testid={inputId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={rows}
required={required}
aria-required={required}
aria-invalid={hasError}
aria-label={label}
className={cn(baseClassName, "h-auto resize-none bg-transparent")}
/>
) : (
<input
id={inputId}
data-testid={inputId}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
pattern={type === "email" ? EMAIL_PATTERN : undefined}
required={required}
aria-required={required}
aria-invalid={hasError}
aria-label={label}
className={cn(baseClassName, "bg-[#1F1F1F66]")}
/>
)}
</div>
);
}

View File

@@ -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<HTMLFormElement>) => {
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 (
<div
data-testid="information-request-form"
className={cn("w-full max-w-[896px] flex flex-col items-center gap-8")}
>
{/* Header */}
<div className={cn("w-full flex flex-col items-center gap-4")}>
<OpenHandsLogoWhite width={56} height={56} />
<div className={cn("text-center flex flex-col gap-2")}>
<Typography.H1 className={cn("text-2xl font-semibold")}>
{title}
</Typography.H1>
<Typography.Text className={cn("text-[#8C8C8C] leading-5")}>
{subtitle}
</Typography.Text>
</div>
</div>
{/* Content: Form + Card */}
<div className={cn("w-full flex flex-col md:flex-row gap-8")}>
{/* Form */}
<form
onSubmit={handleSubmit}
className={cn("flex-1 flex flex-col gap-4 w-full md:max-w-[544px]")}
>
<FormInput
id="name"
label={t(I18nKey.ENTERPRISE$FORM_NAME_LABEL)}
value={formData.name}
placeholder={t(I18nKey.ENTERPRISE$FORM_NAME_PLACEHOLDER)}
required
showError={hasAttemptedSubmit}
onChange={(value) => onFormDataChange({ ...formData, name: value })}
/>
<FormInput
id="company"
label={t(I18nKey.ENTERPRISE$FORM_COMPANY_LABEL)}
value={formData.company}
placeholder={t(I18nKey.ENTERPRISE$FORM_COMPANY_PLACEHOLDER)}
required
showError={hasAttemptedSubmit}
onChange={(value) =>
onFormDataChange({ ...formData, company: value })
}
/>
<FormInput
id="email"
label={t(I18nKey.ENTERPRISE$FORM_EMAIL_LABEL)}
type="email"
value={formData.email}
placeholder={t(I18nKey.ENTERPRISE$FORM_EMAIL_PLACEHOLDER)}
required
showError={hasAttemptedSubmit}
onChange={(value) =>
onFormDataChange({ ...formData, email: value })
}
/>
<FormInput
id="message"
label={t(I18nKey.ENTERPRISE$FORM_MESSAGE_LABEL)}
value={formData.message}
placeholder={messagePlaceholder}
rows={4}
required
showError={hasAttemptedSubmit}
onChange={(value) =>
onFormDataChange({ ...formData, message: value })
}
/>
{/* Buttons */}
<div
className={cn("flex gap-4 mt-4")}
role="group"
aria-label="Form actions"
>
<button
type="button"
onClick={onBack}
aria-label={t(I18nKey.COMMON$BACK)}
className={cn(
"flex-1 px-6 py-2.5 text-sm text-center rounded",
"bg-transparent text-white border border-[#242424]",
"hover:bg-white hover:text-black transition-colors cursor-pointer",
)}
>
{t(I18nKey.COMMON$BACK)}
</button>
<button
type="submit"
disabled={isSubmitting}
aria-label={t(I18nKey.ENTERPRISE$FORM_SUBMIT)}
className={cn(
"flex-1 px-6 py-2.5 text-sm rounded",
"bg-white text-black border border-white",
"hover:bg-gray-100 transition-colors cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed",
)}
>
{isSubmitting
? t(I18nKey.ENTERPRISE$FORM_SUBMITTING)
: t(I18nKey.ENTERPRISE$FORM_SUBMIT)}
</button>
</div>
</form>
{/* CTA Card */}
<Card
theme="dark"
gradient="standard"
className={cn("w-full md:w-80 flex-col p-6 gap-4")}
>
<div className={cn("w-10 h-10")}>
{isSaas ? (
<CloudIcon className={cn("w-10 h-10 text-[#8C8C8C]")} />
) : (
<StackedIcon className={cn("w-10 h-10")} />
)}
</div>
<Typography.H3
className={cn("text-xl font-semibold leading-7 text-[#FAFAFA]")}
>
{cardTitle}
</Typography.H3>
<Typography.Text
className={cn(
"relative top-[0.5px] font-inter text-[#8C8C8C]",
"font-400 text-14px leading-[22.75px] tracking-[0px]",
)}
>
{cardDescription}
</Typography.Text>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<ModalBackdrop
onClose={onClose}
aria-label={t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_TITLE)}
>
<div
data-testid="request-submitted-modal"
className={cn(
"w-[448px] bg-black rounded-md shadow-lg",
"border border-[#242424] border-t-[#242424]",
)}
>
{/* Header with close button */}
<div className={cn("relative p-6 pb-0")}>
<button
type="button"
onClick={onClose}
aria-label={t(I18nKey.MODAL$CLOSE_BUTTON_LABEL)}
className={cn(
"absolute top-[17px] right-[17px] w-4 h-4",
"flex items-center justify-center rounded-sm cursor-pointer",
"opacity-70 hover:opacity-100 transition-opacity",
)}
>
<CloseIcon className={cn("w-4 h-4")} />
</button>
{/* Title and description */}
<div className={cn("flex flex-col gap-1.5 pr-8")}>
<Typography.H2
className={cn("text-lg leading-[18px] tracking-[-0.45px]")}
>
{t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_TITLE)}
</Typography.H2>
<Typography.Text className={cn("text-[#8C8C8C] leading-5")}>
{t(I18nKey.ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION)}
</Typography.Text>
</div>
</div>
{/* Footer with Done button */}
<div className={cn("p-6 pt-4 flex justify-end")}>
<button
type="button"
onClick={onClose}
aria-label={t(I18nKey.ENTERPRISE$DONE_BUTTON)}
className={cn(
"px-4 py-2 text-sm font-medium rounded cursor-pointer",
"bg-white text-black hover:bg-gray-100 transition-colors",
)}
>
{t(I18nKey.ENTERPRISE$DONE_BUTTON)}
</button>
</div>
</div>
</ModalBackdrop>
);
}

View File

@@ -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) => (
<StepInput
<FormInput
key={field.id}
id={field.id}
label={field.label}

View File

@@ -1,27 +0,0 @@
interface StepInputProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}
export function StepInput({ id, label, value, onChange }: StepInputProps) {
return (
<div className="flex flex-col gap-1.5 w-full">
<label
htmlFor={`step-input-${id}`}
className="text-sm font-medium text-neutral-400 cursor-pointer"
>
{label}
</label>
<input
id={`step-input-${id}`}
data-testid={`step-input-${id}`}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors"
/>
</div>
);
}

View File

@@ -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.

View File

@@ -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,
};
};

View File

@@ -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",
}

View File

@@ -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": "Закрити"
}
}

View File

@@ -0,0 +1,3 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.1663 31.6673H14.9996C12.8361 31.6668 10.7153 31.0646 8.87424 29.9281C7.03322 28.7916 5.54448 27.1656 4.5744 25.2317C3.60432 23.2978 3.19111 21.1322 3.38094 18.977C3.57077 16.8218 4.35616 14.7618 5.64936 13.0273C6.94256 11.2927 8.69261 9.95197 10.704 9.1548C12.7153 8.35762 14.9087 8.13544 17.039 8.51309C19.1694 8.89074 21.1527 9.85334 22.7675 11.2933C24.3823 12.7333 25.5648 14.5939 26.1829 16.6673H29.1663C31.1554 16.6673 33.0631 17.4575 34.4696 18.864C35.8761 20.2705 36.6663 22.1782 36.6663 24.1673C36.6663 26.1564 35.8761 28.0641 34.4696 29.4706C33.0631 30.8771 31.1554 31.6673 29.1663 31.6673Z" stroke="currentColor" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 4L4 12M4 4L12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

View File

@@ -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"),

View File

@@ -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<RequestType | null>(null);
const [saasFormData, setSaasFormData] =
useState<EnterpriseFormData>(DEFAULT_FORM_DATA);
const [selfHostedFormData, setSelfHostedFormData] =
useState<EnterpriseFormData>(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 (
<main
data-testid="information-request-page"
className={cn(
"min-h-screen flex items-center justify-center bg-base p-4",
)}
>
<div
className={cn(
"w-full max-w-4xl flex flex-col items-center gap-8 p-6",
)}
>
<InformationRequestForm
requestType={selectedRequestType}
formData={currentFormData}
onFormDataChange={handleFormDataChange}
onBack={handleFormBack}
/>
</div>
</main>
);
}
return (
<main
data-testid="information-request-page"
className={cn(
"min-h-screen flex items-center justify-center bg-base p-4",
)}
>
<div
className={cn(
"w-full max-w-4xl flex flex-col items-center gap-[16px] p-6",
)}
>
{/* Logo */}
<OpenHandsLogoWhite width={56} height={56} />
{/* Header */}
<div className={cn("text-center flex flex-col gap-3")}>
<Typography.H1 className={cn("text-2xl font-bold")}>
{t(I18nKey.ENTERPRISE$GET_OPENHANDS_TITLE)}
</Typography.H1>
<Typography.Text className={cn("text-[#8C8C8C] max-w-lg")}>
{t(I18nKey.ENTERPRISE$GET_OPENHANDS_SUBTITLE)}
</Typography.Text>
</div>
{/* Cards */}
<div className={cn("w-full flex flex-col md:flex-row gap-4")}>
<EnterpriseCard
icon={<CloudIcon className={cn("w-10 h-10 text-[#8C8C8C]")} />}
title={t(I18nKey.ENTERPRISE$SAAS_TITLE)}
description={t(I18nKey.ENTERPRISE$SAAS_DESCRIPTION)}
features={saasFeatures}
onLearnMore={() => handleLearnMore("saas")}
learnMoreLabel={t(I18nKey.ENTERPRISE$LEARN_MORE)}
/>
<EnterpriseCard
icon={<StackedIcon className={cn("w-10 h-10")} />}
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)}
/>
</div>
{/* Back Link */}
<Link
to="/login"
aria-label={t(I18nKey.COMMON$BACK)}
className={cn(
"px-6 py-2.5 text-sm rounded-sm",
"bg-[#050505] text-white border border-[#242424]",
"hover:bg-white hover:text-black transition-colors",
)}
>
{t(I18nKey.COMMON$BACK)}
</Link>
</div>
</main>
);
}

View File

@@ -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 && (
<RequestSubmittedModal onClose={handleRequestModalClose} />
)}
</>
);
}

View File

@@ -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 (
<ModalBackdrop>
<div
data-testid="onboarding-form"
className="w-[500px] max-w-[calc(100vw-2rem)] mx-auto p-4 sm:p-6 flex flex-col justify-center overflow-hidden"
>
<div className="flex flex-col items-center mb-4">
<OpenHandsLogoWhite width={55} height={55} />
</div>
<StepHeader
title={t(currentStep.questionKey)}
subtitle={
currentStep.subtitleKey ? t(currentStep.subtitleKey) : undefined
}
currentStep={currentStepIndex + 1}
totalSteps={steps.length}
/>
<StepContent
options={translatedOptions}
inputFields={translatedInputFields}
selectedOptionIds={currentSelections}
inputValues={inputValues}
onSelectOption={handleSelectOption}
onInputChange={handleInputChange}
/>
<div
data-testid="onboarding-form"
className="w-[500px] max-w-[calc(100vw-2rem)] mx-auto p-4 sm:p-6 flex flex-col justify-center overflow-hidden"
data-testid="step-actions"
className="flex justify-end items-center gap-3"
>
<div className="flex flex-col items-center mb-4">
<OpenHandsLogoWhite width={55} height={55} />
</div>
<StepHeader
title={t(currentStep.questionKey)}
subtitle={
currentStep.subtitleKey ? t(currentStep.subtitleKey) : undefined
}
currentStep={currentStepIndex + 1}
totalSteps={steps.length}
/>
<StepContent
options={translatedOptions}
inputFields={translatedInputFields}
selectedOptionIds={currentSelections}
inputValues={inputValues}
onSelectOption={handleSelectOption}
onInputChange={handleInputChange}
/>
<div
data-testid="step-actions"
className="flex justify-end items-center gap-3"
>
{!isFirstStep && (
<BrandButton
type="button"
variant="secondary"
onClick={handleBack}
className="flex-1 px-4 sm:px-6 py-2.5 bg-[050505] text-white border hover:bg-white border-[#242424] hover:text-black"
>
{t(I18nKey.ONBOARDING$BACK_BUTTON)}
</BrandButton>
)}
{!isFirstStep && (
<BrandButton
type="button"
variant="primary"
onClick={handleNext}
isDisabled={!isStepComplete}
className={cn(
"px-4 sm:px-6 py-2.5 bg-white text-black hover:bg-white/90",
isFirstStep ? "w-1/2" : "flex-1",
)}
variant="secondary"
onClick={handleBack}
className="flex-1 px-4 sm:px-6 py-2.5 bg-[050505] text-white border hover:bg-white border-[#242424] hover:text-black"
>
{t(
isLastStep
? I18nKey.ONBOARDING$FINISH_BUTTON
: I18nKey.ONBOARDING$NEXT_BUTTON,
)}
{t(I18nKey.ONBOARDING$BACK_BUTTON)}
</BrandButton>
</div>
)}
<BrandButton
type="button"
variant="primary"
onClick={handleNext}
isDisabled={!isStepComplete}
className={cn(
"px-4 sm:px-6 py-2.5 bg-white text-black hover:bg-white/90",
isFirstStep ? "w-1/2" : "flex-1",
)}
>
{t(
isLastStep
? I18nKey.ONBOARDING$FINISH_BUTTON
: I18nKey.ONBOARDING$NEXT_BUTTON,
)}
</BrandButton>
</div>
</ModalBackdrop>
</div>
);
}

View File

@@ -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<typeof cardVariants> {
testId?: string;
}
export function Card({ children, className, testId, theme }: CardProps) {
export function Card({
children,
className,
testId,
theme,
hover,
gradient,
}: CardProps) {
return (
<div
data-testid={testId}
className={cn(cardVariants({ theme }), className)}
className={cn(cardVariants({ theme, hover, gradient }), className)}
>
{children}
</div>

View File

@@ -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));
};