diff --git a/frontend/__tests__/components/features/onboarding/form-input.test.tsx b/frontend/__tests__/components/features/onboarding/form-input.test.tsx
index 8b6ce57fb4..19152852dd 100644
--- a/frontend/__tests__/components/features/onboarding/form-input.test.tsx
+++ b/frontend/__tests__/components/features/onboarding/form-input.test.tsx
@@ -1,7 +1,10 @@
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";
+import {
+ FormInput,
+ isValidEmail,
+} from "#/components/features/onboarding/form-input";
describe("FormInput", () => {
const defaultProps = {
@@ -168,5 +171,45 @@ describe("FormInput", () => {
const textarea = screen.getByTestId("form-input-test-input");
expect(textarea).toHaveClass("border-red-500");
});
+
+ it("should show error border for invalid email when showError is true", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).toHaveClass("border-red-500");
+ });
+
+ it("should not show error border for valid email when showError is true", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByTestId("form-input-test-input");
+ expect(input).not.toHaveClass("border-red-500");
+ });
+ });
+
+ describe("isValidEmail", () => {
+ it("should return true for valid emails", () => {
+ expect(isValidEmail("test@example.com")).toBe(true);
+ expect(isValidEmail("user.name@domain.org")).toBe(true);
+ expect(isValidEmail("user+tag@domain.co.uk")).toBe(true);
+ });
+
+ it("should return false for invalid emails", () => {
+ expect(isValidEmail("")).toBe(false);
+ expect(isValidEmail("a@b")).toBe(false);
+ expect(isValidEmail("invalid")).toBe(false);
+ expect(isValidEmail("@domain.com")).toBe(false);
+ expect(isValidEmail("user@")).toBe(false);
+ expect(isValidEmail("user@.com")).toBe(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
index 03fb61e2e5..ac617d84ba 100644
--- a/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx
+++ b/frontend/__tests__/components/features/onboarding/information-request-form.test.tsx
@@ -299,5 +299,82 @@ describe("InformationRequestForm", () => {
// 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);
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockTrackEnterpriseLeadFormSubmitted).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("loading state", () => {
+ it("should disable submit button after 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",
+ });
+ await user.click(submitButton);
+
+ expect(submitButton).toBeDisabled();
+ });
+
+ it("should show submitting text after 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",
+ });
+ await user.click(submitButton);
+
+ expect(screen.getByText("ENTERPRISE$FORM_SUBMITTING")).toBeInTheDocument();
+ });
+
+ 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);
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/frontend/src/components/features/onboarding/form-input.tsx b/frontend/src/components/features/onboarding/form-input.tsx
index f9d186b067..5bc4d6b5f9 100644
--- a/frontend/src/components/features/onboarding/form-input.tsx
+++ b/frontend/src/components/features/onboarding/form-input.tsx
@@ -1,3 +1,6 @@
+export const isValidEmail = (email: string): boolean =>
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+
interface FormInputProps {
id: string;
label: string;
@@ -22,7 +25,9 @@ export function FormInput({
showError = false,
}: FormInputProps) {
const inputId = `form-input-${id}`;
- const hasError = showError && required && !value.trim();
+ const isEmailInvalid =
+ type === "email" && !!value.trim() && !isValidEmail(value.trim());
+ const hasError = showError && ((required && !value.trim()) || isEmailInvalid);
const inputClassName = `w-full min-h-10 rounded border bg-[#050505] px-3 py-2 text-sm leading-5 text-white placeholder:text-[#8C8C8C] placeholder:leading-5 focus:outline-none transition-colors ${
hasError
? "border-red-500 focus:border-red-500"
diff --git a/frontend/src/components/features/onboarding/information-request-form.tsx b/frontend/src/components/features/onboarding/information-request-form.tsx
index f30c7df809..95da7dec3c 100644
--- a/frontend/src/components/features/onboarding/information-request-form.tsx
+++ b/frontend/src/components/features/onboarding/information-request-form.tsx
@@ -5,7 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { useTracking } from "#/hooks/use-tracking";
import { Card } from "#/ui/card";
import { Text } from "#/ui/typography";
-import { FormInput } from "./form-input";
+import { FormInput, isValidEmail } 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";
@@ -31,22 +31,27 @@ export function InformationRequestForm({
message: "",
});
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
+ if (isSubmitting) return;
setHasAttemptedSubmit(true);
- // Check if all required fields are filled
+ // Check if all required fields are filled and email is valid
const isValid =
formData.name.trim() &&
formData.company.trim() &&
formData.email.trim() &&
+ isValidEmail(formData.email.trim()) &&
formData.message.trim();
if (!isValid) {
return;
}
+ setIsSubmitting(true);
+
// TODO: Implement actual form submission API call
// Track form submission in PostHog
trackEnterpriseLeadFormSubmitted({
@@ -170,10 +175,13 @@ export function InformationRequestForm({
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index d01e869c88..7717fb8618 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -1219,6 +1219,7 @@ export enum I18nKey {
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",
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index f2fdd72856..2cf0dab31d 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -20728,6 +20728,23 @@
"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": "リクエストが送信されました",