feat(frontend): home page cta (#13339)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
HeyItsChloe
2026-03-17 13:44:36 -07:00
committed by GitHub
parent af1fa8961a
commit d3a8b037f2
7 changed files with 606 additions and 2 deletions

View File

@@ -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<string, string> = {
"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(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
};
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");
});
});
});

View File

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

View File

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

View File

@@ -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<SetStateAction<boolean>>;
}
export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleClose = () => {
setCTADismissed("homepage");
setShouldShowCTA(false);
};
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "home_page" });
};
return (
<Card theme="dark" className={cn("w-[320px] cta-card-gradient")}>
<button
type="button"
onClick={handleClose}
className={cn(
"absolute top-3 right-3 size-7 rounded-full",
"border border-[#242424] bg-[#0A0A0A]",
"flex items-center justify-center",
"text-white/60 hover:text-white cursor-pointer",
"shadow-[0px_1px_2px_-1px_#0000001A,0px_1px_3px_0px_#0000001A]",
)}
aria-label="Close"
>
<CloseIcon width={16} height={16} />
</button>
<div className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<CardTitle className="font-inter font-semibold text-xl leading-7 tracking-normal text-[#FAFAFA]">
{t(I18nKey.CTA$ENTERPRISE_TITLE)}
</CardTitle>
<Typography.Text className="font-inter font-normal text-sm leading-5 tracking-normal text-[#8C8C8C]">
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
</Typography.Text>
</div>
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"w-fit h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#1a1a1a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</Card>
);
}

View File

@@ -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";
<PrefetchPageLinks page="/conversations/:conversationId" />;
function HomeScreen() {
const { data: config } = useConfig();
const [selectedRepo, setSelectedRepo] = React.useState<GitRepository | null>(
null,
);
const [shouldShowCTA, setShouldShowCTA] = React.useState(
() => !isCTADismissed("homepage"),
);
const isSaasMode = config?.app_mode === "saas";
return (
<div
data-testid="home-screen"
@@ -40,6 +51,12 @@ function HomeScreen() {
<TaskSuggestions filterFor={selectedRepo} />
</div>
</div>
{isSaasMode && shouldShowCTA && ENABLE_PROJ_USER_JOURNEY() && (
<div className="fixed bottom-4 right-8 z-50 md:bottom-6 md:right-12">
<HomepageCTA setShouldShowCTA={setShouldShowCTA} />
</div>
)}
</div>
);
}

View File

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

View File

@@ -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";