mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
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:
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
140
frontend/__tests__/utils/local-storage.test.ts
Normal file
140
frontend/__tests__/utils/local-storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
75
frontend/src/components/features/home/homepage-cta.tsx
Normal file
75
frontend/src/components/features/home/homepage-cta.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user