From af1fa8961af6501c2734a3bb559649c2ed4a9a04 Mon Sep 17 00:00:00 2001 From: HeyItsChloe <54480367+HeyItsChloe@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:14:59 -0700 Subject: [PATCH] feat(frontend): login page cta (#13337) Co-authored-by: openhands --- .../features/auth/login-content.test.tsx | 67 +++++ .../features/auth/login-cta.test.tsx | 63 +++++ frontend/__tests__/routes/login.test.tsx | 5 + .../features/auth/login-content.tsx | 231 +++++++++--------- .../components/features/auth/login-cta.tsx | 66 +++++ frontend/src/i18n/declaration.ts | 6 + frontend/src/i18n/translation.json | 96 ++++++++ frontend/src/tailwind.css | 2 +- 8 files changed, 425 insertions(+), 111 deletions(-) create mode 100644 frontend/__tests__/components/features/auth/login-cta.test.tsx create mode 100644 frontend/src/components/features/auth/login-cta.tsx diff --git a/frontend/__tests__/components/features/auth/login-content.test.tsx b/frontend/__tests__/components/features/auth/login-content.test.tsx index a331ee2378..176b15ea95 100644 --- a/frontend/__tests__/components/features/auth/login-content.test.tsx +++ b/frontend/__tests__/components/features/auth/login-content.test.tsx @@ -49,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({ displayErrorToast: vi.fn(), })); +// Mock feature flags - we'll control the return value in each test +const mockEnableProjUserJourney = vi.fn(() => true); +vi.mock("#/utils/feature-flags", () => ({ + ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(), +})); + describe("LoginContent", () => { beforeEach(() => { vi.stubGlobal("location", { href: "" }); + // Reset mock to return true by default + mockEnableProjUserJourney.mockReturnValue(true); }); afterEach(() => { @@ -274,6 +282,65 @@ describe("LoginContent", () => { expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument(); }); + it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => { + render( + + + , + ); + + expect(screen.getByTestId("login-cta")).toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => { + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when appMode is null", () => { + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + + it("should not display the enterprise LoginCTA component when feature flag is disabled", () => { + // Disable the feature flag + mockEnableProjUserJourney.mockReturnValue(false); + + render( + + + , + ); + + expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument(); + }); + it("should display invitation pending message when hasInvitation is true", () => { render( diff --git a/frontend/__tests__/components/features/auth/login-cta.test.tsx b/frontend/__tests__/components/features/auth/login-cta.test.tsx new file mode 100644 index 0000000000..1a17d6d285 --- /dev/null +++ b/frontend/__tests__/components/features/auth/login-cta.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { LoginCTA } from "#/components/features/auth/login-cta"; + +// Mock useTracking hook +const mockTrackSaasSelfhostedInquiry = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry, + }), +})); + +describe("LoginCTA", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render enterprise CTA with title and description", () => { + render(); + + expect(screen.getByTestId("login-cta")).toBeInTheDocument(); + expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument(); + expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument(); + }); + + it("should render all enterprise feature list items", () => { + render(); + + expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument(); + expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument(); + expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument(); + 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 () => { + const user = userEvent.setup(); + render(); + + const learnMoreLink = screen.getByRole("link", { + name: "CTA$LEARN_MORE", + }); + await user.click(learnMoreLink); + + expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ + location: "login_page", + }); + }); +}); diff --git a/frontend/__tests__/routes/login.test.tsx b/frontend/__tests__/routes/login.test.tsx index 8158413dfc..bc247f9d97 100644 --- a/frontend/__tests__/routes/login.test.tsx +++ b/frontend/__tests__/routes/login.test.tsx @@ -73,6 +73,11 @@ vi.mock("#/hooks/use-invitation", () => ({ useInvitation: () => useInvitationMock(), })); +// Mock feature flags - enable by default for tests +vi.mock("#/utils/feature-flags", () => ({ + ENABLE_PROJ_USER_JOURNEY: () => true, +})); + const RouterStub = createRoutesStub([ { Component: LoginPage, diff --git a/frontend/src/components/features/auth/login-content.tsx b/frontend/src/components/features/auth/login-content.tsx index fbae5df4ea..b317bd5543 100644 --- a/frontend/src/components/features/auth/login-content.tsx +++ b/frontend/src/components/features/auth/login-content.tsx @@ -13,6 +13,9 @@ import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-not import { useRecaptcha } from "#/hooks/use-recaptcha"; import { useConfig } from "#/hooks/query/use-config"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { cn } from "#/utils/utils"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; +import { LoginCTA } from "./login-cta"; export interface LoginContentProps { githubAuthUrl: string | null; @@ -177,125 +180,133 @@ export function LoginContent({ return (
-
- -
+
+
+ +
-

- {t(I18nKey.AUTH$LETS_GET_STARTED)} -

+

+ {t(I18nKey.AUTH$LETS_GET_STARTED)} +

+ + {shouldShownHelperText && ( +
+ {emailVerified && ( +

+ {t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} +

+ )} + {hasDuplicatedEmail && ( +

+ {t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} +

+ )} + {recaptchaBlocked && ( +

+ {t(I18nKey.AUTH$RECAPTCHA_BLOCKED)} +

+ )} + {hasInvitation && ( +

+ {t(I18nKey.AUTH$INVITATION_PENDING)} +

+ )} + {showBitbucket && ( +

+ {t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)} +

+ )} +
+ )} - {shouldShownHelperText && (
- {emailVerified && ( -

- {t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)} -

- )} - {hasDuplicatedEmail && ( -

- {t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)} -

- )} - {recaptchaBlocked && ( -

- {t(I18nKey.AUTH$RECAPTCHA_BLOCKED)} -

- )} - {hasInvitation && ( -

- {t(I18nKey.AUTH$INVITATION_PENDING)} -

- )} - {showBitbucket && ( -

- {t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)} -

+ {noProvidersConfigured ? ( +
+ {t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)} +
+ ) : ( + <> + {showGithub && ( + + )} + + {showGitlab && ( + + )} + + {showBitbucket && ( + + )} + + {showBitbucketDataCenter && ( + + )} + + {showEnterpriseSso && ( + + )} + )}
- )} -
- {noProvidersConfigured ? ( -
- {t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)} -
- ) : ( - <> - {showGithub && ( - - )} - - {showGitlab && ( - - )} - - {showBitbucket && ( - - )} - - {showBitbucketDataCenter && ( - - )} - - {showEnterpriseSso && ( - - )} - - )} +
- + {appMode === "saas" && ENABLE_PROJ_USER_JOURNEY() && }
); } diff --git a/frontend/src/components/features/auth/login-cta.tsx b/frontend/src/components/features/auth/login-cta.tsx new file mode 100644 index 0000000000..1d67b9cedb --- /dev/null +++ b/frontend/src/components/features/auth/login-cta.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import { Card } from "#/ui/card"; +import { CardTitle } from "#/ui/card-title"; +import { Typography } from "#/ui/typography"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; +import StackedIcon from "#/icons/stacked.svg?react"; +import { useTracking } from "#/hooks/use-tracking"; + +export function LoginCTA() { + const { t } = useTranslation(); + const { trackSaasSelfhostedInquiry } = useTracking(); + + const handleLearnMoreClick = () => { + trackSaasSelfhostedInquiry({ location: "login_page" }); + }; + + return ( + +
+
+ +
+ + {t(I18nKey.CTA$ENTERPRISE)} + + + {t(I18nKey.CTA$ENTERPRISE_DEPLOY)} + + +
    +
  • {t(I18nKey.CTA$FEATURE_ON_PREMISES)}
  • +
  • {t(I18nKey.CTA$FEATURE_DATA_CONTROL)}
  • +
  • {t(I18nKey.CTA$FEATURE_COMPLIANCE)}
  • +
  • {t(I18nKey.CTA$FEATURE_SUPPORT)}
  • +
+ + +
+
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 81b1301ace..94f72e7c95 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1141,6 +1141,12 @@ export enum I18nKey { ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON", ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON", ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON", + CTA$ENTERPRISE = "CTA$ENTERPRISE", + CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY", + CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES", + CTA$FEATURE_DATA_CONTROL = "CTA$FEATURE_DATA_CONTROL", + CTA$FEATURE_COMPLIANCE = "CTA$FEATURE_COMPLIANCE", + CTA$FEATURE_SUPPORT = "CTA$FEATURE_SUPPORT", ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED", ENTERPRISE$TITLE = "ENTERPRISE$TITLE", ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index da7b4f391f..c712b2a2e1 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -18255,6 +18255,102 @@ "tr": "Bitir", "uk": "Завершити" }, + "CTA$ENTERPRISE": { + "en": "Enterprise", + "ja": "エンタープライズ", + "zh-CN": "企业版", + "zh-TW": "企業版", + "ko-KR": "엔터프라이즈", + "no": "Bedrift", + "ar": "المؤسسات", + "de": "Unternehmen", + "fr": "Entreprise", + "it": "Azienda", + "pt": "Empresarial", + "es": "Empresa", + "tr": "Kurumsal", + "uk": "Підприємство" + }, + "CTA$ENTERPRISE_DEPLOY": { + "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.", + "tr": "OpenHands'i kendi altyapınızda dağıtın. Veri, uyumluluk ve güvenlik üzerinde tam kontrol.", + "uk": "Розгорніть OpenHands на власній інфраструктурі. Повний контроль над даними, відповідністю та безпекою." + }, + "CTA$FEATURE_ON_PREMISES": { + "en": "On-premises or private cloud", + "ja": "オンプレミスまたはプライベートクラウド", + "zh-CN": "本地部署或私有云", + "zh-TW": "本地部署或私有雲", + "ko-KR": "온프레미스 또는 프라이빗 클라우드", + "no": "Lokalt eller privat sky", + "ar": "محلي أو سحابة خاصة", + "de": "On-Premises oder Private Cloud", + "fr": "Sur site ou cloud privé", + "it": "On-premise o cloud privato", + "pt": "Local ou nuvem privada", + "es": "Local o nube privada", + "tr": "Şirket içi veya özel bulut", + "uk": "Локально або приватна хмара" + }, + "CTA$FEATURE_DATA_CONTROL": { + "en": "Full data control", + "ja": "完全なデータ管理", + "zh-CN": "完全数据控制", + "zh-TW": "完全資料控制", + "ko-KR": "완전한 데이터 제어", + "no": "Full datakontroll", + "ar": "تحكم كامل في البيانات", + "de": "Volle Datenkontrolle", + "fr": "Contrôle total des données", + "it": "Controllo completo dei dati", + "pt": "Controle total de dados", + "es": "Control total de datos", + "tr": "Tam veri kontrolü", + "uk": "Повний контроль даних" + }, + "CTA$FEATURE_COMPLIANCE": { + "en": "Custom compliance requirements", + "ja": "カスタムコンプライアンス要件", + "zh-CN": "自定义合规要求", + "zh-TW": "自訂合規要求", + "ko-KR": "사용자 정의 규정 준수 요구 사항", + "no": "Tilpassede samsvarkrav", + "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", + "tr": "Özel uyumluluk gereksinimleri", + "uk": "Індивідуальні вимоги відповідності" + }, + "CTA$FEATURE_SUPPORT": { + "en": "Dedicated support options", + "ja": "専用サポートオプション", + "zh-CN": "专属支持选项", + "zh-TW": "專屬支援選項", + "ko-KR": "전용 지원 옵션", + "no": "Dedikerte støttealternativer", + "ar": "خيارات دعم مخصصة", + "de": "Dedizierte Supportoptionen", + "fr": "Options de support dédiées", + "it": "Opzioni di supporto dedicate", + "pt": "Opções de suporte dedicado", + "es": "Opciones de soporte dedicado", + "tr": "Özel destek seçenekleri", + "uk": "Виділені варіанти підтримки" + }, "ENTERPRISE$SELF_HOSTED": { "en": "Self-Hosted", "ja": "セルフホスト", diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index 8cbacea7ef..afa6a9aef7 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -379,6 +379,6 @@ /* CTA card gradient and shadow */ .cta-card-gradient { - background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%); + background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%), #000000; box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1); }