diff --git a/frontend/__tests__/components/features/home/homepage-cta.test.tsx b/frontend/__tests__/components/features/home/homepage-cta.test.tsx new file mode 100644 index 0000000000..f44f6bd93b --- /dev/null +++ b/frontend/__tests__/components/features/home/homepage-cta.test.tsx @@ -0,0 +1,159 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { HomepageCTA } from "#/components/features/home/homepage-cta"; + +// Mock the translation function +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise", + "CTA$ENTERPRISE_DESCRIPTION": + "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before", + "CTA$LEARN_MORE": "Learn More", + }; + return translations[key] || key; + }, + i18n: { language: "en" }, + }), + }; +}); + +// Mock local storage +vi.mock("#/utils/local-storage", () => ({ + setCTADismissed: vi.fn(), +})); + +// Mock useTracking hook +const mockTrackSaasSelfhostedInquiry = vi.fn(); +vi.mock("#/hooks/use-tracking", () => ({ + useTracking: () => ({ + trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry, + }), +})); + +import { setCTADismissed } from "#/utils/local-storage"; + +describe("HomepageCTA", () => { + const mockSetShouldShowCTA = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderHomepageCTA = () => { + return render(); + }; + + describe("rendering", () => { + it("renders the enterprise title", () => { + renderHomepageCTA(); + expect( + screen.getByText("Get OpenHands for Enterprise"), + ).toBeInTheDocument(); + }); + + it("renders the enterprise description", () => { + renderHomepageCTA(); + expect( + screen.getByText(/Cloud allows you to access OpenHands anywhere/), + ).toBeInTheDocument(); + }); + + it("renders the Learn More link", () => { + renderHomepageCTA(); + const link = screen.getByRole("link", { name: "Learn More" }); + expect(link).toBeInTheDocument(); + }); + + it("renders the close button with correct aria-label", () => { + renderHomepageCTA(); + expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); + }); + }); + + describe("close button behavior", () => { + it("calls setCTADismissed with 'homepage' when close button is clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(setCTADismissed).toHaveBeenCalledWith("homepage"); + }); + + it("calls setShouldShowCTA with false when close button is clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false); + }); + + it("calls both setCTADismissed and setShouldShowCTA in order", async () => { + const user = userEvent.setup(); + const callOrder: string[] = []; + + vi.mocked(setCTADismissed).mockImplementation(() => { + callOrder.push("setCTADismissed"); + }); + mockSetShouldShowCTA.mockImplementation(() => { + callOrder.push("setShouldShowCTA"); + }); + + renderHomepageCTA(); + + const closeButton = screen.getByRole("button", { name: "Close" }); + await user.click(closeButton); + + expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]); + }); + }); + + describe("Learn More link behavior", () => { + it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => { + const user = userEvent.setup(); + renderHomepageCTA(); + + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + await user.click(learnMoreLink); + + expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({ + location: "home_page", + }); + }); + + it("has correct href and target attributes", () => { + renderHomepageCTA(); + + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + expect(learnMoreLink).toHaveAttribute( + "href", + "https://openhands.dev/enterprise/", + ); + expect(learnMoreLink).toHaveAttribute("target", "_blank"); + expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + describe("accessibility", () => { + it("close button is focusable", () => { + renderHomepageCTA(); + const closeButton = screen.getByRole("button", { name: "Close" }); + expect(closeButton).not.toHaveAttribute("tabindex", "-1"); + }); + + it("Learn More link is focusable", () => { + renderHomepageCTA(); + const learnMoreLink = screen.getByRole("link", { name: "Learn More" }); + expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1"); + }); + }); +}); diff --git a/frontend/__tests__/routes/home-screen.test.tsx b/frontend/__tests__/routes/home-screen.test.tsx index 27d6825820..05049121fa 100644 --- a/frontend/__tests__/routes/home-screen.test.tsx +++ b/frontend/__tests__/routes/home-screen.test.tsx @@ -609,3 +609,193 @@ describe("New user welcome toast", () => { ).not.toBeInTheDocument(); }); }); + +describe("HomepageCTA visibility", () => { + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + const getSettingsSpy = vi.spyOn(SettingsService, "getSettings"); + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(AuthService, "authenticate").mockResolvedValue(true); + + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + // Mock localStorage to enable the PROJ_USER_JOURNEY feature flag (CTA dismissal also uses localStorage) + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "FEATURE_PROJ_USER_JOURNEY") { + return "true"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should show HomepageCTA in SaaS mode when not dismissed and feature flag enabled", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + const ctaLink = await screen.findByRole("link", { + name: "CTA$LEARN_MORE", + }); + expect(ctaLink).toBeInTheDocument(); + }); + + it("should not show HomepageCTA in OSS mode even with feature flag enabled", async () => { + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "oss", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not show HomepageCTA when feature flag is disabled", async () => { + // Override localStorage to disable the feature flag + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), // No feature flags set + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); + + it("should not show HomepageCTA when dismissed in local storage", async () => { + // Override localStorage to mark CTA as dismissed while keeping the feature flag enabled + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => { + if (key === "FEATURE_PROJ_USER_JOURNEY") { + return "true"; + } + if (key === "homepage-cta-dismissed") { + return "true"; + } + return null; + }), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + + useIsAuthedMock.mockReturnValue({ + data: true, + isLoading: false, + isFetching: false, + isError: false, + }); + useConfigMock.mockReturnValue({ + data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS }, + isLoading: false, + }); + + getConfigSpy.mockResolvedValue({ + app_mode: "saas", + posthog_client_key: "test-posthog-key", + providers_configured: ["github"], + auth_url: "https://auth.example.com", + feature_flags: DEFAULT_FEATURE_FLAGS, + maintenance_start_time: null, + recaptcha_site_key: null, + faulty_models: [], + error_message: null, + updated_at: "2024-01-14T10:00:00Z", + github_app_slug: null, + }); + + renderHomeScreen(); + + await screen.findByTestId("home-screen"); + + expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/utils/local-storage.test.ts b/frontend/__tests__/utils/local-storage.test.ts new file mode 100644 index 0000000000..69be6899bc --- /dev/null +++ b/frontend/__tests__/utils/local-storage.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + LOCAL_STORAGE_KEYS, + LoginMethod, + setLoginMethod, + getLoginMethod, + clearLoginData, + setCTADismissed, + isCTADismissed, +} from "#/utils/local-storage"; + +describe("local-storage utilities", () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe("Login method utilities", () => { + describe("setLoginMethod", () => { + it("stores the login method in local storage", () => { + setLoginMethod(LoginMethod.GITHUB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github"); + }); + + it("stores different login methods correctly", () => { + setLoginMethod(LoginMethod.GITLAB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab"); + + setLoginMethod(LoginMethod.BITBUCKET); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket"); + + setLoginMethod(LoginMethod.AZURE_DEVOPS); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops"); + + setLoginMethod(LoginMethod.ENTERPRISE_SSO); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("enterprise_sso"); + + setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center"); + }); + + it("overwrites previous login method", () => { + setLoginMethod(LoginMethod.GITHUB); + setLoginMethod(LoginMethod.GITLAB); + expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab"); + }); + }); + + describe("getLoginMethod", () => { + it("returns null when no login method is set", () => { + expect(getLoginMethod()).toBeNull(); + }); + + it("returns the stored login method", () => { + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "github"); + expect(getLoginMethod()).toBe(LoginMethod.GITHUB); + }); + + it("returns correct login method for all types", () => { + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "gitlab"); + expect(getLoginMethod()).toBe(LoginMethod.GITLAB); + + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "bitbucket"); + expect(getLoginMethod()).toBe(LoginMethod.BITBUCKET); + + localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "azure_devops"); + expect(getLoginMethod()).toBe(LoginMethod.AZURE_DEVOPS); + }); + }); + + describe("clearLoginData", () => { + it("removes the login method from local storage", () => { + setLoginMethod(LoginMethod.GITHUB); + expect(getLoginMethod()).toBe(LoginMethod.GITHUB); + + clearLoginData(); + expect(getLoginMethod()).toBeNull(); + }); + + it("does not throw when no login method is set", () => { + expect(() => clearLoginData()).not.toThrow(); + }); + }); + }); + + describe("CTA utilities", () => { + describe("isCTADismissed", () => { + it("returns false when CTA has not been dismissed", () => { + expect(isCTADismissed("homepage")).toBe(false); + }); + + it("returns true when CTA has been dismissed", () => { + localStorage.setItem("homepage-cta-dismissed", "true"); + expect(isCTADismissed("homepage")).toBe(true); + }); + + it("returns false when storage value is not 'true'", () => { + localStorage.setItem("homepage-cta-dismissed", "false"); + expect(isCTADismissed("homepage")).toBe(false); + + localStorage.setItem("homepage-cta-dismissed", "invalid"); + expect(isCTADismissed("homepage")).toBe(false); + }); + }); + + describe("setCTADismissed", () => { + it("sets the CTA as dismissed in local storage", () => { + setCTADismissed("homepage"); + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + }); + + it("generates correct key for homepage location", () => { + setCTADismissed("homepage"); + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + }); + }); + + describe("storage key format", () => { + it("uses the correct key format: {location}-cta-dismissed", () => { + setCTADismissed("homepage"); + + // Verify key exists with correct format + expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true"); + + // Verify other keys don't exist + expect(localStorage.getItem("cta-dismissed")).toBeNull(); + expect(localStorage.getItem("homepage")).toBeNull(); + }); + }); + + describe("persistence", () => { + it("dismissed state persists across multiple reads", () => { + setCTADismissed("homepage"); + + expect(isCTADismissed("homepage")).toBe(true); + expect(isCTADismissed("homepage")).toBe(true); + expect(isCTADismissed("homepage")).toBe(true); + }); + }); + }); +}); diff --git a/frontend/src/components/features/home/homepage-cta.tsx b/frontend/src/components/features/home/homepage-cta.tsx new file mode 100644 index 0000000000..af55498550 --- /dev/null +++ b/frontend/src/components/features/home/homepage-cta.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { Dispatch, SetStateAction } from "react"; +import { Card } from "#/ui/card"; +import { CardTitle } from "#/ui/card-title"; +import { Typography } from "#/ui/typography"; +import { cn } from "#/utils/utils"; +import { I18nKey } from "#/i18n/declaration"; +import { setCTADismissed } from "#/utils/local-storage"; +import { useTracking } from "#/hooks/use-tracking"; +import CloseIcon from "#/icons/close.svg?react"; + +interface HomepageCTAProps { + setShouldShowCTA: Dispatch>; +} + +export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) { + const { t } = useTranslation(); + const { trackSaasSelfhostedInquiry } = useTracking(); + + const handleClose = () => { + setCTADismissed("homepage"); + setShouldShowCTA(false); + }; + + const handleLearnMoreClick = () => { + trackSaasSelfhostedInquiry({ location: "home_page" }); + }; + + return ( + + + +
+
+ + {t(I18nKey.CTA$ENTERPRISE_TITLE)} + + + + {t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)} + +
+ + + {t(I18nKey.CTA$LEARN_MORE)} + +
+
+ ); +} diff --git a/frontend/src/routes/home.tsx b/frontend/src/routes/home.tsx index 5c3679414c..0d6069d62b 100644 --- a/frontend/src/routes/home.tsx +++ b/frontend/src/routes/home.tsx @@ -6,14 +6,25 @@ import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestio import { GitRepository } from "#/types/git"; import { NewConversation } from "#/components/features/home/new-conversation/new-conversation"; import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations"; +import { HomepageCTA } from "#/components/features/home/homepage-cta"; +import { isCTADismissed } from "#/utils/local-storage"; +import { useConfig } from "#/hooks/query/use-config"; +import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags"; ; function HomeScreen() { + const { data: config } = useConfig(); const [selectedRepo, setSelectedRepo] = React.useState( null, ); + const [shouldShowCTA, setShouldShowCTA] = React.useState( + () => !isCTADismissed("homepage"), + ); + + const isSaasMode = config?.app_mode === "saas"; + return (
+ + {isSaasMode && shouldShowCTA && ENABLE_PROJ_USER_JOURNEY() && ( +
+ +
+ )} ); } diff --git a/frontend/src/utils/feature-flags.ts b/frontend/src/utils/feature-flags.ts index 5c4b4ce0cf..442acba50d 100644 --- a/frontend/src/utils/feature-flags.ts +++ b/frontend/src/utils/feature-flags.ts @@ -18,7 +18,7 @@ export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB"); export const ENABLE_TRAJECTORY_REPLAY = () => loadFeatureFlag("TRAJECTORY_REPLAY"); export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); -export const ENABLE_SANDBOX_GROUPING = () => - loadFeatureFlag("SANDBOX_GROUPING"); export const ENABLE_PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY"); +export const ENABLE_SANDBOX_GROUPING = () => + loadFeatureFlag("SANDBOX_GROUPING"); diff --git a/frontend/src/utils/local-storage.ts b/frontend/src/utils/local-storage.ts index 919d97225e..a942e6fab5 100644 --- a/frontend/src/utils/local-storage.ts +++ b/frontend/src/utils/local-storage.ts @@ -36,3 +36,26 @@ export const getLoginMethod = (): LoginMethod | null => { export const clearLoginData = (): void => { localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD); }; + +// CTA locations that can be dismissed +export type CTALocation = "homepage"; + +// Generate storage key for a CTA location +const getCTAKey = (location: CTALocation): string => + `${location}-cta-dismissed`; + +/** + * Set a CTA as dismissed in local storage (persists across tabs) + * @param location The CTA location to dismiss + */ +export const setCTADismissed = (location: CTALocation): void => { + localStorage.setItem(getCTAKey(location), "true"); +}; + +/** + * Check if a CTA has been dismissed + * @param location The CTA location to check + * @returns true if dismissed, false otherwise + */ +export const isCTADismissed = (location: CTALocation): boolean => + localStorage.getItem(getCTAKey(location)) === "true";