From abdc58cd281692751d45f1dcdc77f339055eeae3 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:41:35 -0700 Subject: [PATCH] feat(frontend): lead capture form (#13496) Co-authored-by: openhands Co-authored-by: hieptl --- .../features/auth/login-cta.test.tsx | 51 +- .../onboarding/enterprise-card.test.tsx | 83 ++ .../features/onboarding/feature-list.test.tsx | 38 + .../features/onboarding/form-input.test.tsx | 171 ++++ .../information-request-form.test.tsx | 367 +++++++++ .../request-submitted-modal.test.tsx | 113 +++ .../features/onboarding/step-input.test.tsx | 72 -- .../hooks/use-is-on-intermediate-page.test.ts | 6 + .../routes/information-request.test.tsx | 174 +++++ frontend/__tests__/ui/card.test.tsx | 154 ++++ .../components/features/auth/login-cta.tsx | 12 +- .../features/onboarding/enterprise-card.tsx | 54 ++ .../features/onboarding/feature-list.tsx | 20 + .../features/onboarding/form-input.tsx | 78 ++ .../onboarding/information-request-form.tsx | 230 ++++++ .../onboarding/request-submitted-modal.tsx | 72 ++ .../features/onboarding/step-content.tsx | 4 +- .../features/onboarding/step-input.tsx | 27 - .../src/hooks/use-is-on-intermediate-page.ts | 6 +- frontend/src/hooks/use-tracking.ts | 24 + frontend/src/i18n/declaration.ts | 43 +- frontend/src/i18n/translation.json | 731 ++++++++++++++---- frontend/src/icons/cloud-minimal.svg | 3 + frontend/src/icons/modal-close.svg | 3 + frontend/src/routes.ts | 1 + frontend/src/routes/information-request.tsx | 181 +++++ frontend/src/routes/login.tsx | 22 +- frontend/src/routes/onboarding-form.tsx | 99 ++- frontend/src/ui/card.tsx | 35 +- frontend/src/utils/local-storage.ts | 54 ++ 30 files changed, 2604 insertions(+), 324 deletions(-) create mode 100644 frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx create mode 100644 frontend/__tests__/components/features/onboarding/feature-list.test.tsx create mode 100644 frontend/__tests__/components/features/onboarding/form-input.test.tsx create mode 100644 frontend/__tests__/components/features/onboarding/information-request-form.test.tsx create mode 100644 frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx delete mode 100644 frontend/__tests__/components/features/onboarding/step-input.test.tsx create mode 100644 frontend/__tests__/routes/information-request.test.tsx create mode 100644 frontend/__tests__/ui/card.test.tsx create mode 100644 frontend/src/components/features/onboarding/enterprise-card.tsx create mode 100644 frontend/src/components/features/onboarding/feature-list.tsx create mode 100644 frontend/src/components/features/onboarding/form-input.tsx create mode 100644 frontend/src/components/features/onboarding/information-request-form.tsx create mode 100644 frontend/src/components/features/onboarding/request-submitted-modal.tsx delete mode 100644 frontend/src/components/features/onboarding/step-input.tsx create mode 100644 frontend/src/icons/cloud-minimal.svg create mode 100644 frontend/src/icons/modal-close.svg create mode 100644 frontend/src/routes/information-request.tsx diff --git a/frontend/__tests__/components/features/auth/login-cta.test.tsx b/frontend/__tests__/components/features/auth/login-cta.test.tsx index 1a17d6d285..76a39bd6c2 100644 --- a/frontend/__tests__/components/features/auth/login-cta.test.tsx +++ b/frontend/__tests__/components/features/auth/login-cta.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; +import { createRoutesStub } from "react-router"; import { LoginCTA } from "#/components/features/auth/login-cta"; // Mock useTracking hook @@ -16,8 +17,23 @@ describe("LoginCTA", () => { vi.clearAllMocks(); }); + const renderWithRouter = () => { + const Stub = createRoutesStub([ + { + path: "/", + Component: LoginCTA, + }, + { + path: "/information-request", + Component: () =>
, + }, + ]); + + return render(); + }; + it("should render enterprise CTA with title and description", () => { - render(); + renderWithRouter(); expect(screen.getByTestId("login-cta")).toBeInTheDocument(); expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument(); @@ -25,7 +41,7 @@ describe("LoginCTA", () => { }); it("should render all enterprise feature list items", () => { - render(); + renderWithRouter(); expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument(); expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument(); @@ -33,23 +49,9 @@ describe("LoginCTA", () => { expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument(); }); - it("should render Learn More as a link with correct href and target", () => { - render(); - - const learnMoreLink = screen.getByRole("link", { - name: "CTA$LEARN_MORE", - }); - expect(learnMoreLink).toHaveAttribute( - "href", - "https://openhands.dev/enterprise/", - ); - expect(learnMoreLink).toHaveAttribute("target", "_blank"); - expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer"); - }); - - it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => { + it("should track and navigate to information request page when Learn More is clicked", async () => { const user = userEvent.setup(); - render(); + renderWithRouter(); const learnMoreLink = screen.getByRole("link", { name: "CTA$LEARN_MORE", @@ -59,5 +61,18 @@ describe("LoginCTA", () => { expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ location: "login_page", }); + expect(screen.getByTestId("information-request-page")).toBeInTheDocument(); + }); + + it("should render Learn More as a link for Open in New Tab support", () => { + renderWithRouter(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + expect(learnMoreLink).toHaveAttribute( + "href", + "/information-request", + ); }); }); diff --git a/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx b/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx new file mode 100644 index 0000000000..1cc6541a55 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/enterprise-card.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router"; +import { EnterpriseCard } from "#/components/features/onboarding/enterprise-card"; + +describe("EnterpriseCard", () => { + const defaultProps = { + icon: , + title: "Test Title", + description: "Test description", + features: ["Feature 1", "Feature 2"], + learnMoreLabel: "Learn More", + onLearnMore: vi.fn(), + }; + + const renderWithRouter = (props = defaultProps) => + render( + + + , + ); + + it("should render the card with title", () => { + renderWithRouter(); + + expect(screen.getByText("Test Title")).toBeInTheDocument(); + }); + + it("should render the description", () => { + renderWithRouter(); + + expect(screen.getByText("Test description")).toBeInTheDocument(); + }); + + it("should render the icon", () => { + renderWithRouter(); + + expect(screen.getByTestId("test-icon")).toBeInTheDocument(); + }); + + it("should render the features", () => { + renderWithRouter(); + + expect(screen.getByText("Feature 1")).toBeInTheDocument(); + expect(screen.getByText("Feature 2")).toBeInTheDocument(); + }); + + it("should render the learn more link with correct label", () => { + renderWithRouter(); + + const link = screen.getByRole("link", { + name: "Learn More Test Title", + }); + expect(link).toBeInTheDocument(); + }); + + it("should have correct href", () => { + renderWithRouter(); + + const link = screen.getByRole("link", { name: "Learn More Test Title" }); + expect(link).toHaveAttribute("href", "/information-request"); + }); + + it("should call onLearnMore when link is clicked", async () => { + const mockOnLearnMore = vi.fn(); + const user = userEvent.setup(); + + renderWithRouter({ ...defaultProps, onLearnMore: mockOnLearnMore }); + + const link = screen.getByRole("link", { name: "Learn More Test Title" }); + await user.click(link); + + expect(mockOnLearnMore).toHaveBeenCalledTimes(1); + }); + + it("should have correct aria-label on link", () => { + renderWithRouter(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("aria-label", "Learn More Test Title"); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/feature-list.test.tsx b/frontend/__tests__/components/features/onboarding/feature-list.test.tsx new file mode 100644 index 0000000000..046e0c602f --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/feature-list.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FeatureList } from "#/components/features/onboarding/feature-list"; + +describe("FeatureList", () => { + it("should render a list of features", () => { + const features = ["Feature 1", "Feature 2", "Feature 3"]; + render(); + + expect(screen.getByText("Feature 1")).toBeInTheDocument(); + expect(screen.getByText("Feature 2")).toBeInTheDocument(); + expect(screen.getByText("Feature 3")).toBeInTheDocument(); + }); + + it("should render bullet points for each feature", () => { + const features = ["Feature 1", "Feature 2"]; + render(); + + const bullets = screen.getAllByText("•"); + expect(bullets).toHaveLength(2); + }); + + it("should render an empty list when no features provided", () => { + render(); + + const list = screen.getByRole("list"); + expect(list).toBeInTheDocument(); + expect(list.children).toHaveLength(0); + }); + + it("should render each feature as a list item", () => { + const features = ["Feature 1", "Feature 2"]; + render(); + + const listItems = screen.getAllByRole("listitem"); + expect(listItems).toHaveLength(2); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/form-input.test.tsx b/frontend/__tests__/components/features/onboarding/form-input.test.tsx new file mode 100644 index 0000000000..0c180f4d47 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/form-input.test.tsx @@ -0,0 +1,171 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { FormInput } from "#/components/features/onboarding/form-input"; + +describe("FormInput", () => { + const defaultProps = { + id: "test-input", + label: "Test Label", + value: "", + onChange: vi.fn(), + }; + + it("should render with correct test id", () => { + render(); + + expect(screen.getByTestId("form-input-test-input")).toBeInTheDocument(); + }); + + it("should render the label", () => { + render(); + + expect(screen.getByText("Test Label")).toBeInTheDocument(); + }); + + it("should display the provided value", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveValue("Hello World"); + }); + + it("should call onChange when user types", async () => { + const mockOnChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + const input = screen.getByTestId("form-input-test-input"); + await user.type(input, "a"); + + expect(mockOnChange).toHaveBeenCalledWith("a"); + }); + + it("should render as a text input by default", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("type", "text"); + }); + + it("should render as an email input when type is email", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("type", "email"); + }); + + it("should render a textarea when rows prop is provided", () => { + render(); + + const textarea = screen.getByTestId("form-input-test-input"); + expect(textarea.tagName).toBe("TEXTAREA"); + expect(textarea).toHaveAttribute("rows", "4"); + }); + + it("should render placeholder text", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("placeholder", "Enter text here"); + }); + + it("should have aria-required attribute when required", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-required", "true"); + }); + + it("should have aria-label attribute", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-label", "Test Label"); + }); + + it("should have required attribute on input when required", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toBeRequired(); + }); + + it("should have required attribute on textarea when required", () => { + render(); + + const textarea = screen.getByTestId("form-input-test-input"); + expect(textarea).toBeRequired(); + }); + + it("should associate label with input via htmlFor", () => { + render(); + + const label = screen.getByText("Test Label"); + const input = screen.getByTestId("form-input-test-input"); + + expect(label).toHaveAttribute("for", "form-input-test-input"); + expect(input).toHaveAttribute("id", "form-input-test-input"); + }); + + describe("error state", () => { + it("should have aria-invalid true when showing error", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "true"); + }); + + it("should have aria-invalid false when not showing error", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "false"); + }); + + it("should have aria-invalid false when showError is true but field has value", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "false"); + }); + + it("should have aria-invalid false when showError is true but field is not required", () => { + render(); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "false"); + }); + + it("should have aria-invalid true on textarea when showError is true and empty", () => { + render(); + + const textarea = screen.getByTestId("form-input-test-input"); + expect(textarea).toHaveAttribute("aria-invalid", "true"); + }); + + it("should have aria-invalid true for invalid email when showError is true", () => { + render( + , + ); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "true"); + }); + + it("should have aria-invalid false for valid email when showError is true", () => { + render( + , + ); + + const input = screen.getByTestId("form-input-test-input"); + expect(input).toHaveAttribute("aria-invalid", "false"); + }); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx new file mode 100644 index 0000000000..1082d44a96 --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx @@ -0,0 +1,367 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { createRoutesStub } from "react-router"; +import { useState } from "react"; +import { + InformationRequestForm, + RequestType, +} from "#/components/features/onboarding/information-request-form"; +import { EnterpriseFormData } from "#/utils/local-storage"; + +// Mock useTracking +const mockTrackEnterpriseLeadFormSubmitted = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackEnterpriseLeadFormSubmitted: mockTrackEnterpriseLeadFormSubmitted, + }), +})); + +const mockOnBack = vi.fn(); + +// Wrapper to manage form state (needed since component is controlled) +function StatefulForm({ requestType }: { requestType: RequestType }) { + const [formData, setFormData] = useState({ name: "", company: "", email: "", message: "" }); + return ; +} + +describe("InformationRequestForm", () => { + const defaultProps = { + requestType: "saas" as RequestType, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnBack.mockClear(); + }); + + const renderWithRouter = (props = defaultProps) => { + const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + }, + { + path: "/login", + Component: () =>
, + }, + { + path: "/information-request", + Component: () =>
, + }, + ]); + + return render(); + }; + + it("should render the form", () => { + renderWithRouter(); + + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + }); + + it("should render the logo", () => { + renderWithRouter(); + + const logo = screen.getByTestId("information-request-form").querySelector("svg"); + expect(logo).toBeInTheDocument(); + }); + + it("should render all form fields", () => { + renderWithRouter(); + + expect(screen.getByTestId("form-input-name")).toBeInTheDocument(); + expect(screen.getByTestId("form-input-company")).toBeInTheDocument(); + expect(screen.getByTestId("form-input-email")).toBeInTheDocument(); + expect(screen.getByTestId("form-input-message")).toBeInTheDocument(); + }); + + it("should render SaaS-specific title when requestType is saas", () => { + renderWithRouter({ ...defaultProps, requestType: "saas" }); + + expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument(); + }); + + it("should render Self-hosted-specific title when requestType is self-hosted", () => { + renderWithRouter({ ...defaultProps, requestType: "self-hosted" }); + + expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument(); + }); + + it("should render cloud icon for SaaS request type", () => { + renderWithRouter({ ...defaultProps, requestType: "saas" }); + + // The card should contain the cloud icon + const card = screen.getByText("ENTERPRISE$SAAS_TITLE").closest("div"); + expect(card).toBeInTheDocument(); + }); + + it("should render stacked icon for self-hosted request type", () => { + renderWithRouter({ ...defaultProps, requestType: "self-hosted" }); + + // The card should contain the stacked icon + const card = screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE").closest("div"); + expect(card).toBeInTheDocument(); + }); + + it("should call onBack when back button is clicked", async () => { + const user = userEvent.setup(); + + renderWithRouter(); + + const backButton = screen.getByRole("button", { name: "COMMON$BACK" }); + await user.click(backButton); + + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it("should update form fields when user types", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const nameInput = screen.getByTestId("form-input-name"); + await user.type(nameInput, "John Doe"); + + expect(nameInput).toHaveValue("John Doe"); + }); + + it("should update email field when user types", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const emailInput = screen.getByTestId("form-input-email"); + await user.type(emailInput, "john@example.com"); + + expect(emailInput).toHaveValue("john@example.com"); + }); + + it("should render message as textarea", () => { + renderWithRouter(); + + const messageInput = screen.getByTestId("form-input-message"); + expect(messageInput.tagName).toBe("TEXTAREA"); + }); + + it("should have all fields marked as required", () => { + renderWithRouter(); + + expect(screen.getByTestId("form-input-name")).toBeRequired(); + expect(screen.getByTestId("form-input-company")).toBeRequired(); + expect(screen.getByTestId("form-input-email")).toBeRequired(); + expect(screen.getByTestId("form-input-message")).toBeRequired(); + }); + + it("should render submit button", () => { + renderWithRouter(); + + const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" }); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveAttribute("type", "submit"); + }); + + it("should render back button", () => { + renderWithRouter(); + + const backButton = screen.getByRole("button", { name: "COMMON$BACK" }); + expect(backButton).toBeInTheDocument(); + expect(backButton).toHaveAttribute("type", "button"); + }); + + it("should have button group with role and aria-label", () => { + renderWithRouter(); + + const buttonGroup = screen.getByRole("group", { name: "Form actions" }); + expect(buttonGroup).toBeInTheDocument(); + }); + + it("should display SaaS card description for saas request type", () => { + renderWithRouter({ ...defaultProps, requestType: "saas" }); + + expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument(); + }); + + it("should display Self-hosted card description for self-hosted request type", () => { + renderWithRouter({ ...defaultProps, requestType: "self-hosted" }); + + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_DESCRIPTION")).toBeInTheDocument(); + }); + + describe("form validation", () => { + it("should not show error state before form submission", () => { + renderWithRouter(); + + const nameInput = screen.getByTestId("form-input-name"); + const companyInput = screen.getByTestId("form-input-company"); + const emailInput = screen.getByTestId("form-input-email"); + const messageInput = screen.getByTestId("form-input-message"); + + expect(nameInput).toHaveAttribute("aria-invalid", "false"); + expect(companyInput).toHaveAttribute("aria-invalid", "false"); + expect(emailInput).toHaveAttribute("aria-invalid", "false"); + expect(messageInput).toHaveAttribute("aria-invalid", "false"); + }); + + it("should not navigate when form is submitted with empty fields", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const submitButton = screen.getByRole("button", { + name: "ENTERPRISE$FORM_SUBMIT", + }); + await user.click(submitButton); + + // Should stay on form page, not navigate to login + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); + }); + + it("should not call tracking when form is submitted with empty fields", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" }); + await user.click(submitButton); + + expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled(); + }); + + it("should navigate to login page when form is submitted with all fields filled", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.type(screen.getByTestId("form-input-name"), "John Doe"); + await user.type(screen.getByTestId("form-input-company"), "Acme Inc"); + await user.type(screen.getByTestId("form-input-email"), "john@example.com"); + await user.type(screen.getByTestId("form-input-message"), "Hello world"); + + const submitButton = screen.getByRole("button", { + name: "ENTERPRISE$FORM_SUBMIT", + }); + await user.click(submitButton); + + // Should navigate to login page + expect(screen.getByTestId("login-page")).toBeInTheDocument(); + }); + + it("should call tracking with form data when form is submitted successfully", async () => { + const user = userEvent.setup(); + renderWithRouter({ ...defaultProps, requestType: "saas" }); + + await user.type(screen.getByTestId("form-input-name"), "John Doe"); + await user.type(screen.getByTestId("form-input-company"), "Acme Inc"); + await user.type(screen.getByTestId("form-input-email"), "john@example.com"); + await user.type(screen.getByTestId("form-input-message"), "Hello world"); + + const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" }); + await user.click(submitButton); + + expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1); + expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({ + requestType: "saas", + name: "John Doe", + company: "Acme Inc", + email: "john@example.com", + message: "Hello world", + }); + }); + + it("should call tracking with self-hosted request type", async () => { + const user = userEvent.setup(); + renderWithRouter({ ...defaultProps, requestType: "self-hosted" }); + + await user.type(screen.getByTestId("form-input-name"), "Jane Smith"); + await user.type(screen.getByTestId("form-input-company"), "Tech Corp"); + await user.type(screen.getByTestId("form-input-email"), "jane@techcorp.com"); + await user.type(screen.getByTestId("form-input-message"), "Interested in self-hosted"); + + const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" }); + await user.click(submitButton); + + expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({ + requestType: "self-hosted", + name: "Jane Smith", + company: "Tech Corp", + email: "jane@techcorp.com", + message: "Interested in self-hosted", + }); + }); + + it("should trim whitespace from form fields before tracking", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.type(screen.getByTestId("form-input-name"), " John Doe "); + await user.type(screen.getByTestId("form-input-company"), " Acme Inc "); + await user.type(screen.getByTestId("form-input-email"), " john@example.com "); + await user.type(screen.getByTestId("form-input-message"), " Hello world "); + + const submitButton = screen.getByRole("button", { name: "ENTERPRISE$FORM_SUBMIT" }); + await user.click(submitButton); + + expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledWith({ + requestType: "saas", + name: "John Doe", + company: "Acme Inc", + email: "john@example.com", + message: "Hello world", + }); + }); + + it("should have valid aria-invalid state when field has value", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const nameInput = screen.getByTestId("form-input-name"); + await user.type(nameInput, "John Doe"); + + // Field with value should not be invalid + expect(nameInput).toHaveAttribute("aria-invalid", "false"); + }); + + it("should not navigate when email is invalid", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.type(screen.getByTestId("form-input-name"), "John Doe"); + await user.type(screen.getByTestId("form-input-company"), "Acme Inc"); + await user.type(screen.getByTestId("form-input-email"), "invalid-email"); + await user.type(screen.getByTestId("form-input-message"), "Hello world"); + + const submitButton = screen.getByRole("button", { + name: "ENTERPRISE$FORM_SUBMIT", + }); + await user.click(submitButton); + + // Should stay on form page, not navigate to login + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + expect(screen.queryByTestId("login-page")).not.toBeInTheDocument(); + expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled(); + }); + }); + + describe("loading state", () => { + it("should prevent double submission", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.type(screen.getByTestId("form-input-name"), "John Doe"); + await user.type(screen.getByTestId("form-input-company"), "Acme Inc"); + await user.type(screen.getByTestId("form-input-email"), "john@example.com"); + await user.type(screen.getByTestId("form-input-message"), "Hello world"); + + const submitButton = screen.getByRole("button", { + name: "ENTERPRISE$FORM_SUBMIT", + }); + + // Click multiple times rapidly + await user.click(submitButton); + await user.click(submitButton); + await user.click(submitButton); + + // Should only track once + expect(mockTrackEnterpriseLeadFormSubmitted).toHaveBeenCalledTimes(1); + // Should navigate to login page + expect(screen.getByTestId("login-page")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx b/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx new file mode 100644 index 0000000000..d5c40bc4eb --- /dev/null +++ b/frontend/__tests__/components/features/onboarding/request-submitted-modal.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { RequestSubmittedModal } from "#/components/features/onboarding/request-submitted-modal"; + +describe("RequestSubmittedModal", () => { + const defaultProps = { + onClose: vi.fn(), + }; + + it("should render the modal", () => { + render(); + + expect(screen.getByTestId("request-submitted-modal")).toBeInTheDocument(); + }); + + it("should render the title", () => { + render(); + + expect( + screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_TITLE"), + ).toBeInTheDocument(); + }); + + it("should render the description", () => { + render(); + + expect( + screen.getByText("ENTERPRISE$REQUEST_SUBMITTED_DESCRIPTION"), + ).toBeInTheDocument(); + }); + + it("should render the Done button", () => { + render(); + + expect( + screen.getByRole("button", { name: "ENTERPRISE$DONE_BUTTON" }), + ).toBeInTheDocument(); + }); + + it("should render the close button", () => { + render(); + + expect( + screen.getByRole("button", { name: "MODAL$CLOSE_BUTTON_LABEL" }), + ).toBeInTheDocument(); + }); + + it("should call onClose when Done button is clicked", async () => { + const mockOnClose = vi.fn(); + const user = userEvent.setup(); + + render(); + + const doneButton = screen.getByRole("button", { + name: "ENTERPRISE$DONE_BUTTON", + }); + await user.click(doneButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should call onClose when close button is clicked", async () => { + const mockOnClose = vi.fn(); + const user = userEvent.setup(); + + render(); + + const closeButton = screen.getByRole("button", { + name: "MODAL$CLOSE_BUTTON_LABEL", + }); + await user.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should call onClose when Escape key is pressed", async () => { + const mockOnClose = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.keyboard("{Escape}"); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should call onClose when backdrop is clicked", async () => { + const mockOnClose = vi.fn(); + const user = userEvent.setup(); + + render(); + + // Click on the backdrop (the semi-transparent overlay) + const backdrop = screen.getByRole("dialog").querySelector(".bg-black"); + if (backdrop) { + await user.click(backdrop); + } + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it("should have proper accessibility attributes", () => { + render(); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveAttribute("aria-modal", "true"); + expect(dialog).toHaveAttribute( + "aria-label", + "ENTERPRISE$REQUEST_SUBMITTED_TITLE", + ); + }); +}); diff --git a/frontend/__tests__/components/features/onboarding/step-input.test.tsx b/frontend/__tests__/components/features/onboarding/step-input.test.tsx deleted file mode 100644 index a4f388efbb..0000000000 --- a/frontend/__tests__/components/features/onboarding/step-input.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import { StepInput } from "#/components/features/onboarding/step-input"; - -describe("StepInput", () => { - const defaultProps = { - id: "test-input", - label: "Test Label", - value: "", - onChange: vi.fn(), - }; - - it("should render with correct test id", () => { - render(); - - expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument(); - }); - - it("should render the label", () => { - render(); - - expect(screen.getByText("Test Label")).toBeInTheDocument(); - }); - - it("should display the provided value", () => { - render(); - - const input = screen.getByTestId("step-input-test-input"); - expect(input).toHaveValue("Hello World"); - }); - - it("should call onChange when user types", async () => { - const mockOnChange = vi.fn(); - const user = userEvent.setup(); - - render(); - - const input = screen.getByTestId("step-input-test-input"); - await user.type(input, "a"); - - expect(mockOnChange).toHaveBeenCalledWith("a"); - }); - - it("should call onChange with the full input value on each keystroke", async () => { - const mockOnChange = vi.fn(); - const user = userEvent.setup(); - - render(); - - const input = screen.getByTestId("step-input-test-input"); - await user.type(input, "abc"); - - expect(mockOnChange).toHaveBeenCalledTimes(3); - expect(mockOnChange).toHaveBeenNthCalledWith(1, "a"); - expect(mockOnChange).toHaveBeenNthCalledWith(2, "b"); - expect(mockOnChange).toHaveBeenNthCalledWith(3, "c"); - }); - - it("should use the id prop for data-testid", () => { - render(); - - expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument(); - }); - - it("should render as a text input", () => { - render(); - - const input = screen.getByTestId("step-input-test-input"); - expect(input).toHaveAttribute("type", "text"); - }); -}); diff --git a/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts index 2879815745..2c9ecdca18 100644 --- a/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts +++ b/frontend/__tests__/hooks/use-is-on-intermediate-page.test.ts @@ -32,6 +32,12 @@ describe("useIsOnIntermediatePage", () => { const { result } = renderHook(() => useIsOnIntermediatePage()); expect(result.current).toBe(true); }); + + it("should return true when on /information-request page", () => { + useLocationMock.mockReturnValue({ pathname: "/information-request" }); + const { result } = renderHook(() => useIsOnIntermediatePage()); + expect(result.current).toBe(true); + }); }); describe("returns false for non-intermediate pages", () => { diff --git a/frontend/__tests__/routes/information-request.test.tsx b/frontend/__tests__/routes/information-request.test.tsx new file mode 100644 index 0000000000..c6b04e9396 --- /dev/null +++ b/frontend/__tests__/routes/information-request.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { MemoryRouter } from "react-router"; +import InformationRequest from "#/routes/information-request"; + +// Mock useNavigate +const mockNavigate = vi.fn(); +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +// Mock useTracking to avoid QueryClient dependency +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackEnterpriseLeadFormSubmitted: vi.fn(), + }), +})); + +describe("InformationRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderWithRouter = () => { + return render( + + + , + ); + }; + + it("should render the page", () => { + renderWithRouter(); + + expect(screen.getByTestId("information-request-page")).toBeInTheDocument(); + }); + + it("should render the logo", () => { + renderWithRouter(); + + const page = screen.getByTestId("information-request-page"); + const logo = page.querySelector("svg"); + expect(logo).toBeInTheDocument(); + }); + + it("should render the page title", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_TITLE")).toBeInTheDocument(); + }); + + it("should render the page subtitle", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_SUBTITLE")).toBeInTheDocument(); + }); + + it("should render SaaS card", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$SAAS_TITLE")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SAAS_DESCRIPTION")).toBeInTheDocument(); + }); + + it("should render Self-hosted card", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_TITLE")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_CARD_DESCRIPTION")).toBeInTheDocument(); + }); + + it("should render SaaS features", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_NO_INFRASTRUCTURE")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_SSO")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_ACCESS_ANYWHERE")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SAAS_FEATURE_AUTO_UPDATES")).toBeInTheDocument(); + }); + + it("should render Self-hosted features", () => { + renderWithRouter(); + + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_ON_PREMISES")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_DATA_CONTROL")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_COMPLIANCE")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$SELF_HOSTED_FEATURE_SUPPORT")).toBeInTheDocument(); + }); + + it("should render two Learn More buttons", () => { + renderWithRouter(); + + const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE"); + expect(learnMoreButtons).toHaveLength(2); + }); + + it("should render back button", () => { + renderWithRouter(); + + expect(screen.getByText("COMMON$BACK")).toBeInTheDocument(); + }); + + it("should have back link pointing to /login", () => { + renderWithRouter(); + + const backLink = screen.getByText("COMMON$BACK"); + expect(backLink).toHaveAttribute("href", "/login"); + }); + + it("should show SaaS form when SaaS Learn More is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + // Click the first Learn More button (SaaS) + const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE"); + await user.click(learnMoreButtons[0]); + + // Form should now be visible + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$FORM_SAAS_TITLE")).toBeInTheDocument(); + }); + + it("should show Self-hosted form when Self-hosted Learn More is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + // Click the second Learn More button (Self-hosted) + const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE"); + await user.click(learnMoreButtons[1]); + + // Form should now be visible + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$FORM_SELF_HOSTED_TITLE")).toBeInTheDocument(); + }); + + it("should return to card selection when form back button is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + // Click Learn More to show form + const learnMoreButtons = screen.getAllByText("ENTERPRISE$LEARN_MORE"); + await user.click(learnMoreButtons[0]); + + // Verify form is visible + expect(screen.getByTestId("information-request-form")).toBeInTheDocument(); + + // Click back button + const backButton = screen.getByRole("button", { name: "COMMON$BACK" }); + await user.click(backButton); + + // Should return to card selection view + expect(screen.queryByTestId("information-request-form")).not.toBeInTheDocument(); + expect(screen.getByText("ENTERPRISE$GET_OPENHANDS_TITLE")).toBeInTheDocument(); + }); + + it("should have accessible Learn More links with aria-label", () => { + renderWithRouter(); + + const saasLink = screen.getByRole("link", { + name: "ENTERPRISE$LEARN_MORE ENTERPRISE$SAAS_TITLE", + }); + const selfHostedLink = screen.getByRole("link", { + name: "ENTERPRISE$LEARN_MORE ENTERPRISE$SELF_HOSTED_TITLE", + }); + + expect(saasLink).toBeInTheDocument(); + expect(selfHostedLink).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/ui/card.test.tsx b/frontend/__tests__/ui/card.test.tsx new file mode 100644 index 0000000000..3445878239 --- /dev/null +++ b/frontend/__tests__/ui/card.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Card } from "#/ui/card"; + +describe("Card", () => { + it("should render children", () => { + render(Card Content); + + expect(screen.getByText("Card Content")).toBeInTheDocument(); + }); + + it("should render with testId", () => { + render(Content); + + expect(screen.getByTestId("test-card")).toBeInTheDocument(); + }); + + it("should apply custom className", () => { + render( + + Content + , + ); + + expect(screen.getByTestId("test-card")).toHaveClass("custom-class"); + }); + + describe("theme variants", () => { + it("should apply default theme styles", () => { + render(Content); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("bg-[#26282D]"); + expect(card).toHaveClass("border-[#727987]"); + expect(card).toHaveClass("rounded-xl"); + }); + + it("should apply outlined theme styles", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("bg-transparent"); + expect(card).toHaveClass("border-[#727987]"); + }); + + it("should apply dark theme styles", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("bg-black"); + expect(card).toHaveClass("border-[#242424]"); + expect(card).toHaveClass("rounded-2xl"); + }); + }); + + describe("hover variants", () => { + it("should not apply hover styles when hover is none", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).not.toHaveClass("hover:bg-[linear-gradient"); + }); + + it("should apply elevated hover styles", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("transition-all"); + expect(card).toHaveClass("duration-200"); + }); + }); + + describe("gradient variants", () => { + it("should not apply gradient styles when gradient is none", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).not.toHaveClass("bg-[#0A0A0A80]"); + }); + + it("should apply standard gradient styles", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("bg-[#0A0A0A80]"); + expect(card).toHaveClass("border-t-[#24242499]"); + }); + }); + + describe("combined variants", () => { + it("should apply dark theme with standard gradient", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + // Should have dark theme base + expect(card).toHaveClass("border-[#242424]"); + expect(card).toHaveClass("rounded-2xl"); + // Should have gradient overlay + expect(card).toHaveClass("bg-[#0A0A0A80]"); + }); + + it("should apply dark theme with elevated hover", () => { + render( + + Content + , + ); + + const card = screen.getByTestId("test-card"); + expect(card).toHaveClass("rounded-2xl"); + expect(card).toHaveClass("transition-all"); + }); + }); + + it("should have flex display by default", () => { + render(Content); + + expect(screen.getByTestId("test-card")).toHaveClass("flex"); + }); + + it("should have relative positioning", () => { + render(Content); + + expect(screen.getByTestId("test-card")).toHaveClass("relative"); + }); +}); diff --git a/frontend/src/components/features/auth/login-cta.tsx b/frontend/src/components/features/auth/login-cta.tsx index 1d67b9cedb..06823837c3 100644 --- a/frontend/src/components/features/auth/login-cta.tsx +++ b/frontend/src/components/features/auth/login-cta.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; import { Card } from "#/ui/card"; import { CardTitle } from "#/ui/card-title"; import { Typography } from "#/ui/typography"; @@ -44,21 +45,20 @@ export function LoginCTA() {
diff --git a/frontend/src/components/features/onboarding/enterprise-card.tsx b/frontend/src/components/features/onboarding/enterprise-card.tsx new file mode 100644 index 0000000000..e4dce5fa85 --- /dev/null +++ b/frontend/src/components/features/onboarding/enterprise-card.tsx @@ -0,0 +1,54 @@ +import { Link } from "react-router"; +import { Card } from "#/ui/card"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; +import { FeatureList } from "./feature-list"; + +interface EnterpriseCardProps { + icon: React.ReactNode; + title: string; + description: string; + features: string[]; + onLearnMore: () => void; + learnMoreLabel: string; +} + +export function EnterpriseCard({ + icon, + title, + description, + features, + onLearnMore, + learnMoreLabel, +}: EnterpriseCardProps) { + return ( + +
{icon}
+ + {title} + + + {description} + + + + {learnMoreLabel} + +
+ ); +} diff --git a/frontend/src/components/features/onboarding/feature-list.tsx b/frontend/src/components/features/onboarding/feature-list.tsx new file mode 100644 index 0000000000..3327fe7efc --- /dev/null +++ b/frontend/src/components/features/onboarding/feature-list.tsx @@ -0,0 +1,20 @@ +import { Typography } from "#/ui/typography"; + +interface FeatureListProps { + features: string[]; +} + +export function FeatureList({ features }: FeatureListProps) { + return ( +
    + {features.map((feature, index) => ( +
  • + + + {feature} + +
  • + ))} +
+ ); +} diff --git a/frontend/src/components/features/onboarding/form-input.tsx b/frontend/src/components/features/onboarding/form-input.tsx new file mode 100644 index 0000000000..a21bc6b629 --- /dev/null +++ b/frontend/src/components/features/onboarding/form-input.tsx @@ -0,0 +1,78 @@ +import { isValidEmail } from "#/utils/input-validation"; +import { cn } from "#/utils/utils"; + +// Email validation pattern - must match EMAIL_REGEX in input-validation.ts +const EMAIL_PATTERN = "[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}"; + +interface FormInputProps { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + type?: "text" | "email"; + rows?: number; + required?: boolean; + showError?: boolean; +} + +export function FormInput({ + id, + label, + value, + onChange, + placeholder, + type = "text", + rows, + required = false, + showError = false, +}: FormInputProps) { + const inputId = `form-input-${id}`; + const isEmailInvalid = + type === "email" && !!value.trim() && !isValidEmail(value.trim()); + const hasError = showError && ((required && !value.trim()) || isEmailInvalid); + const baseClassName = cn( + "w-full min-h-10 rounded border border-[#242424] px-3 py-2 text-sm leading-5 text-white placeholder:text-[#8C8C8C] placeholder:leading-5 focus:outline-none transition-colors focus:border-white", + ); + + return ( +
+ + {rows ? ( +