feat(frontend): context menu cta (#13338)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
HeyItsChloe
2026-03-17 12:52:02 -07:00
committed by GitHub
parent 7516b53f5a
commit 3b215c4ad1
22 changed files with 582 additions and 156 deletions

View File

@@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
describe("ContextMenuContainer", () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
it("should render children", () => {
render(
<ContextMenuContainer onClose={onCloseMock}>
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
</ContextMenuContainer>,
);
expect(screen.getByTestId("child-1")).toBeInTheDocument();
expect(screen.getByTestId("child-2")).toBeInTheDocument();
});
it("should apply consistent base styling", () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div>Content</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
expect(container).toHaveClass("bg-[#050505]");
expect(container).toHaveClass("border");
expect(container).toHaveClass("border-[#242424]");
expect(container).toHaveClass("rounded-[12px]");
expect(container).toHaveClass("p-[25px]");
expect(container).toHaveClass("context-menu-box-shadow");
});
it("should call onClose when clicking outside", async () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div>Content</div>
</ContextMenuContainer>,
);
await user.click(document.body);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should render children in a flex row layout", () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
const innerDiv = container.firstChild as HTMLElement;
expect(innerDiv).toHaveClass("flex");
expect(innerDiv).toHaveClass("flex-row");
expect(innerDiv).toHaveClass("gap-4");
});
it("should apply additional className when provided", () => {
render(
<ContextMenuContainer
onClose={onCloseMock}
testId="test-container"
className="custom-class"
>
<div>Content</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
expect(container).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
describe("ContextMenuCTA", () => {
it("should render the CTA component", () => {
render(<ContextMenuCTA />);
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
});
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
const user = userEvent.setup();
render(<ContextMenuCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "context_menu",
});
});
it("should render Learn More as a link with correct href and target", () => {
render(<ContextMenuCTA />);
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 render the stacked icon", () => {
render(<ContextMenuCTA />);
const contentContainer = screen.getByTestId("context-menu-cta-content");
const icon = contentContainer.querySelector("svg");
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("width", "40");
expect(icon).toHaveAttribute("height", "40");
});
});

View File

@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
}),
}));
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/utils/feature-flags", () => ({
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
}));
describe("EnterpriseBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
describe("Feature Flag", () => {
it("should not render when proj_user_journey feature flag is disabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
const { container } = renderWithProviders(<EnterpriseBanner />);
@@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => {
});
it("should render when proj_user_journey feature flag is enabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
renderWithProviders(<EnterpriseBanner />);

View File

@@ -18,6 +18,27 @@ import { OrganizationMember } from "#/types/org";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
// Mock useBreakpoint hook
vi.mock("#/hooks/use-breakpoint", () => ({
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
}));
// Mock feature flags
const mockEnableProjUserJourney = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
}));
// Mock useTracking hook for CTA
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: vi.fn(),
}),
}));
// Import the mocked modules
import * as breakpoint from "#/hooks/use-breakpoint";
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
function UserContextMenuWithRootOutlet({
@@ -123,6 +144,9 @@ describe("UserContextMenu", () => {
// Ensure clean state at the start of each test
vi.restoreAllMocks();
useSelectedOrganizationStore.setState({ organizationId: null });
// Reset feature flag and breakpoint mocks to defaults
mockEnableProjUserJourney.mockReturnValue(true);
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
});
afterEach(() => {
@@ -630,4 +654,77 @@ describe("UserContextMenu", () => {
// Verify that the dropdown shows the selected organization
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
});
describe("Context Menu CTA", () => {
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
});
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
});
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
// Set OSS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "oss" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Set mobile mode
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Disable the feature flag
mockEnableProjUserJourney.mockReturnValue(false);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
});
});

View File

@@ -5,12 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import DeviceVerify from "#/routes/device-verify";
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
const { useIsAuthedMock, ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
useIsAuthedMock: vi.fn(() => ({
data: false as boolean | undefined,
isLoading: false,
})),
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
@@ -24,7 +24,7 @@ vi.mock("posthog-js/react", () => ({
}));
vi.mock("#/utils/feature-flags", () => ({
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
}));
const RouterStub = createRoutesStub([
@@ -67,7 +67,7 @@ describe("DeviceVerify", () => {
),
);
// Enable feature flag by default
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
afterEach(() => {
@@ -254,7 +254,7 @@ describe("DeviceVerify", () => {
});
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,

View File

@@ -0,0 +1,39 @@
import React from "react";
import { cn } from "#/utils/utils";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
interface ContextMenuContainerProps {
children: React.ReactNode;
onClose: () => void;
testId?: string;
className?: string;
}
export function ContextMenuContainer({
children,
onClose,
testId,
className,
}: ContextMenuContainerProps) {
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
return (
<div
ref={ref}
data-testid={testId}
className={cn(
// Base styling - same for ALL modes (SaaS, OSS, mobile, desktop)
"absolute rounded-[12px] p-[25px]",
"bg-[#050505] border border-[#242424]",
"text-white overflow-hidden z-[9999]",
"context-menu-box-shadow",
// Positioning
"right-0 md:right-auto md:left-full md:bottom-0",
"w-fit",
className,
)}
>
<div className="flex flex-row gap-4 items-stretch">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import StackedIcon from "#/icons/stacked.svg?react";
import { useTracking } from "#/hooks/use-tracking";
export function ContextMenuCTA() {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "context_menu" });
};
return (
<Card
testId="context-menu-cta"
theme="dark"
className={cn(
"w-[286px] min-h-[200px] rounded-[6px]",
"flex-col justify-end",
"cta-card-gradient",
)}
>
<div
data-testid="context-menu-cta-content"
className={cn("flex flex-col gap-[11px] p-[25px]")}
>
<StackedIcon width={40} height={40} />
<CardTitle>{t(I18nKey.CTA$ENTERPRISE_TITLE)}</CardTitle>
<Typography.Text
className={cn(
"text-[#8C8C8C] font-inter font-normal",
"text-[14px] leading-[20px]",
)}
>
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
</Typography.Text>
<div className="flex mt-auto">
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"h-[40px] px-4 rounded-[4px]",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#0a0a0a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</div>
</Card>
);
}

View File

@@ -3,7 +3,7 @@ import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import { H2, Text } from "#/ui/typography";
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
@@ -16,7 +16,7 @@ export function EnterpriseBanner() {
const { t } = useTranslation();
const posthog = usePostHog();
if (!PROJ_USER_JOURNEY()) {
if (!ENABLE_PROJ_USER_JOURNEY()) {
return null;
}

View File

@@ -10,7 +10,7 @@ export function NewConversation() {
const { t } = useTranslation();
return (
<Card>
<Card className="flex-col p-5 gap-2.5 min-h-[286px] md:min-h-auto w-full">
<CardTitle icon={<PlusIcon width={17} height={14} />}>
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
</CardTitle>

View File

@@ -56,6 +56,7 @@ export function OrgSelector() {
label: getOrgDisplayName(org),
})) || []
}
className="bg-[#1F1F1F66] border-[#242424]"
/>
);
}

View File

@@ -9,7 +9,6 @@ import {
import { FiUsers } from "react-icons/fi";
import { useLogout } from "#/hooks/mutation/use-logout";
import { OrganizationUserRole } from "#/types/org";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
import { cn } from "#/utils/utils";
import { OrgSelector } from "../org/org-selector";
@@ -18,11 +17,16 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import DocumentIcon from "#/icons/document.svg?react";
import { Divider } from "#/ui/divider";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuContainer } from "../context-menu/context-menu-container";
import { ContextMenuCTA } from "../context-menu/context-menu-cta";
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
import { useBreakpoint } from "#/hooks/use-breakpoint";
import { useConfig } from "#/hooks/query/use-config";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
// Shared className for context menu list items in the user context menu
const contextMenuListItemClassName = cn(
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded",
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded text-xs",
);
interface UserContextMenuProps {
@@ -40,9 +44,10 @@ export function UserContextMenu({
const navigate = useNavigate();
const { mutate: logout } = useLogout();
const { isPersonalOrg } = useOrgTypeAndAccess();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const settingsNavItems = useSettingsNavItems();
const shouldHideSelector = useShouldHideOrgSelector();
const isMobile = useBreakpoint(768);
const { data: config } = useConfig();
// Filter out org routes since they're handled separately via buttons in this menu
const navItems = settingsNavItems.filter(
@@ -51,7 +56,10 @@ export function UserContextMenu({
);
const isMember = type === "member";
const isSaasMode = config?.app_mode === "saas";
// CTA only renders in SaaS desktop with feature flag enabled
const showCta = isSaasMode && !isMobile && ENABLE_PROJ_USER_JOURNEY();
const handleLogout = () => {
logout();
onClose();
@@ -73,96 +81,93 @@ export function UserContextMenu({
};
return (
<div
data-testid="user-context-menu"
ref={ref}
className={cn(
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
"text-sm absolute left-full bottom-0 z-101",
)}
>
<h3 className="text-lg font-semibold text-white">
{t(I18nKey.ORG$ACCOUNT)}
</h3>
<ContextMenuContainer testId="user-context-menu" onClose={onClose}>
<div className="flex flex-col gap-3 w-[248px]">
<h3 className="text-lg font-semibold text-white">
{t(I18nKey.ORG$ACCOUNT)}
</h3>
<div className="flex flex-col items-start gap-2">
{!shouldHideSelector && (
<div className="w-full relative">
<OrgSelector />
</div>
)}
<div className="flex flex-col items-start gap-2">
{!shouldHideSelector && (
<div className="w-full relative">
<OrgSelector />
</div>
)}
{!isMember && !isPersonalOrg && (
<div className="flex flex-col items-start gap-0 w-full">
<ContextMenuListItem
onClick={handleInviteMemberClick}
className={contextMenuListItemClassName}
>
<IoPersonAddOutline className="text-white" size={16} />
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
<ContextMenuListItem
onClick={handleManageAccountClick}
className={contextMenuListItemClassName}
>
<IoCardOutline className="text-white" size={16} />
{t(I18nKey.COMMON$ORGANIZATION)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleManageOrganizationMembersClick}
className={contextMenuListItemClassName}
>
<FiUsers className="text-white shrink-0" size={16} />
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
</div>
)}
{!isMember && !isPersonalOrg && (
<div className="flex flex-col items-start gap-0 w-full">
<ContextMenuListItem
onClick={handleInviteMemberClick}
className={contextMenuListItemClassName}
>
<IoPersonAddOutline className="text-white" size={14} />
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
<ContextMenuListItem
onClick={handleManageAccountClick}
className={contextMenuListItemClassName}
>
<IoCardOutline className="text-white" size={14} />
{t(I18nKey.COMMON$ORGANIZATION)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleManageOrganizationMembersClick}
className={contextMenuListItemClassName}
>
<FiUsers className="text-white shrink-0" size={14} />
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
>
{React.cloneElement(item.icon, {
className: "text-white",
width: 16,
height: 16,
} as React.SVGProps<SVGSVGElement>)}
{t(item.text)}
</Link>
))}
</div>
)}
<div className="flex flex-col items-start gap-0 w-full">
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
<Divider className="my-1.5" />
<div className="flex flex-col items-start gap-0 w-full">
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
>
{React.cloneElement(item.icon, {
className: "text-white",
width: 14,
height: 14,
} as React.SVGProps<SVGSVGElement>)}
{t(item.text)}
</Link>
))}
</div>
<DocumentIcon className="text-white" width={16} height={16} />
{t(I18nKey.SIDEBAR$DOCS)}
</a>
<Divider className="my-1.5" />
<div className="flex flex-col items-start gap-0 w-full">
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
>
<DocumentIcon className="text-white" width={14} height={14} />
{t(I18nKey.SIDEBAR$DOCS)}
</a>
<ContextMenuListItem
onClick={handleLogout}
className={contextMenuListItemClassName}
>
<IoLogOutOutline className="text-white" size={14} />
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleLogout}
className={contextMenuListItemClassName}
>
<IoLogOutOutline className="text-white" size={16} />
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</div>
</div>
</div>
</div>
{showCta && <ContextMenuCTA />}
</ContextMenuContainer>
);
}

View File

@@ -122,6 +122,13 @@ export const useTracking = () => {
});
};
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
posthog.capture("saas_selfhosted_inquiry", {
location,
...commonProperties,
});
};
return {
trackLoginButtonClick,
trackConversationCreated,
@@ -134,5 +141,6 @@ export const useTracking = () => {
trackCreditLimitReached,
trackAddTeamMembersButtonClick,
trackOnboardingCompleted,
trackSaasSelfhostedInquiry,
};
};

View File

@@ -1171,4 +1171,7 @@ export enum I18nKey {
DEVICE$CONTINUE = "DEVICE$CONTINUE",
DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED",
DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT",
CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE",
CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION",
CTA$LEARN_MORE = "CTA$LEARN_MORE",
}

View File

@@ -18734,5 +18734,53 @@
"es": "Por favor, inicia sesión para autorizar tu dispositivo.",
"tr": "Cihazınızı yetkilendirmek için lütfen giriş yapın.",
"uk": "Будь ласка, увійдіть, щоб авторизувати свій пристрій."
},
"CTA$ENTERPRISE_TITLE": {
"en": "Get OpenHands For Enterprise",
"ja": "エンタープライズ向けOpenHandsを入手",
"zh-CN": "获取企业版 OpenHands",
"zh-TW": "獲取企業版 OpenHands",
"ko-KR": "기업용 OpenHands 받기",
"no": "Få OpenHands for bedrift",
"it": "Ottieni OpenHands per Enterprise",
"pt": "Obtenha o OpenHands para Enterprise",
"es": "Obtén OpenHands para Empresas",
"ar": "احصل على OpenHands للمؤسسات",
"fr": "Obtenez OpenHands pour Entreprise",
"tr": "Kurumsal OpenHands'i Edinin",
"de": "OpenHands für Unternehmen",
"uk": "Отримайте OpenHands для підприємств"
},
"CTA$ENTERPRISE_DESCRIPTION": {
"en": "Cloud allows you to access OpenHands anywhere and coordinate with your team like never before.",
"ja": "クラウドを使用すると、どこからでもOpenHandsにアクセスし、チームとこれまでにない方法で連携できます。",
"zh-CN": "云端让您可以随时随地访问 OpenHands并以前所未有的方式与团队协作。",
"zh-TW": "雲端讓您可以隨時隨地存取 OpenHands並以前所未有的方式與團隊協作。",
"ko-KR": "클라우드를 통해 어디서나 OpenHands에 접속하고 팀과 이전과는 다른 방식으로 협업할 수 있습니다.",
"no": "Cloud lar deg få tilgang til OpenHands hvor som helst og koordinere med teamet ditt som aldri før.",
"it": "Cloud ti permette di accedere a OpenHands ovunque e coordinare con il tuo team come mai prima d'ora.",
"pt": "O Cloud permite que você acesse o OpenHands de qualquer lugar e coordene com sua equipe como nunca antes.",
"es": "Cloud le permite acceder a OpenHands desde cualquier lugar y coordinar con su equipo como nunca antes.",
"ar": "يتيح لك Cloud الوصول إلى OpenHands من أي مكان والتنسيق مع فريقك بشكل لم يسبق له مثيل.",
"fr": "Cloud vous permet d'accéder à OpenHands n'importe où et de coordonner avec votre équipe comme jamais auparavant.",
"tr": "Cloud, OpenHands'e her yerden erişmenizi ve ekibinizle daha önce hiç olmadığı gibi koordinasyon sağlamanızı mümkün kılar.",
"de": "Cloud ermöglicht Ihnen den Zugriff auf OpenHands von überall und die Koordination mit Ihrem Team wie nie zuvor.",
"uk": "Cloud дозволяє отримати доступ до OpenHands будь-де та координувати роботу з вашою командою як ніколи раніше."
},
"CTA$LEARN_MORE": {
"en": "Learn more",
"ja": "詳細を見る",
"zh-CN": "了解更多",
"zh-TW": "了解更多",
"ko-KR": "자세히 알아보기",
"no": "Lær mer",
"it": "Scopri di più",
"pt": "Saiba mais",
"es": "Más información",
"ar": "اعرف المزيد",
"fr": "En savoir plus",
"tr": "Daha fazla bilgi",
"de": "Mehr erfahren",
"uk": "Дізнатися більше"
}
}

View File

@@ -0,0 +1,6 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.334 3.33398H6.66732C4.82637 3.33398 3.33398 4.82637 3.33398 6.66732V13.334C3.33398 15.1749 4.82637 16.6673 6.66732 16.6673H33.334C35.1749 16.6673 36.6673 15.1749 36.6673 13.334V6.66732C36.6673 4.82637 35.1749 3.33398 33.334 3.33398Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M33.334 23.334H6.66732C4.82637 23.334 3.33398 24.8264 3.33398 26.6673V33.334C3.33398 35.1749 4.82637 36.6673 6.66732 36.6673H33.334C35.1749 36.6673 36.6673 35.1749 36.6673 33.334V26.6673C36.6673 24.8264 35.1749 23.334 33.334 23.334Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 10H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 30H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 999 B

View File

@@ -5,7 +5,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
import { I18nKey } from "#/i18n/declaration";
import { H1 } from "#/ui/typography";
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
export default function DeviceVerify() {
const { t } = useTranslation();
@@ -16,7 +16,7 @@ export default function DeviceVerify() {
messageKey: I18nKey;
} | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const showEnterpriseBanner = PROJ_USER_JOURNEY();
const showEnterpriseBanner = ENABLE_PROJ_USER_JOURNEY();
// Get user_code from URL parameters
const userCode = searchParams.get("user_code");

View File

@@ -376,3 +376,9 @@
animation: shine 2s linear infinite;
background: radial-gradient(circle at center, rgb(24 24 27 / 85%), transparent) -200% 50% / 200% 100% no-repeat, #f4f4f5;
}
/* 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%);
box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
}

View File

@@ -2,43 +2,30 @@ import { ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/utils/utils";
const cardVariants = cva(
"w-full flex flex-col rounded-[12px] p-[20px] border border-[#727987] bg-[#26282D] relative",
{
variants: {
gap: {
default: "gap-[10px]",
large: "gap-6",
},
minHeight: {
default: "min-h-[286px] md:min-h-auto",
small: "min-h-[263.5px]",
},
},
defaultVariants: {
gap: "default",
minHeight: "default",
const cardVariants = cva("flex", {
variants: {
theme: {
default: "relative bg-[#26282D] border border-[#727987] rounded-xl",
outlined: "relative bg-transparent border border-[#727987] rounded-xl",
dark: "relative bg-black border border-[#242424] rounded-2xl",
},
},
);
defaultVariants: {
theme: "default",
},
});
interface CardProps extends VariantProps<typeof cardVariants> {
children: ReactNode;
children?: ReactNode;
className?: string;
testId?: string;
}
export function Card({
children,
className = "",
testId,
gap,
minHeight,
}: CardProps) {
export function Card({ children, className, testId, theme }: CardProps) {
return (
<div
data-testid={testId}
className={cn(cardVariants({ gap, minHeight }), className)}
className={cn(cardVariants({ theme }), className)}
>
{children}
</div>

View File

@@ -2,42 +2,53 @@ import React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "#/utils/utils";
const contextMenuVariants = cva(
"absolute bg-tertiary rounded-[6px] text-white overflow-hidden z-50 context-menu-box-shadow",
{
variants: {
size: {
compact: "py-1 px-1",
default: "py-[6px] px-1",
},
layout: {
vertical: "flex flex-col gap-2",
},
position: {
top: "bottom-full",
bottom: "top-full",
},
spacing: {
default: "mt-2",
},
alignment: {
left: "left-0",
right: "right-0",
},
const contextMenuVariants = cva("text-white overflow-hidden z-50", {
variants: {
theme: {
default:
"absolute bg-tertiary rounded-[6px] context-menu-box-shadow py-[6px] px-1",
naked: "relative",
},
defaultVariants: {
size: "default",
layout: "vertical",
spacing: "default",
size: {
compact: "py-1 px-1",
default: "",
},
layout: {
vertical: "flex flex-col gap-2",
},
position: {
top: "bottom-full",
bottom: "top-full",
},
spacing: {
default: "mt-2",
none: "",
},
alignment: {
left: "left-0",
right: "right-0",
},
},
);
compoundVariants: [
{
theme: "naked",
className: "shadow-none",
},
],
defaultVariants: {
theme: "default",
size: "default",
layout: "vertical",
spacing: "default",
},
});
interface ContextMenuProps {
ref?: React.RefObject<HTMLUListElement | null>;
testId?: string;
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLUListElement>["className"];
theme?: VariantProps<typeof contextMenuVariants>["theme"];
size?: VariantProps<typeof contextMenuVariants>["size"];
layout?: VariantProps<typeof contextMenuVariants>["layout"];
position?: VariantProps<typeof contextMenuVariants>["position"];
@@ -50,6 +61,7 @@ export function ContextMenu({
children,
className,
ref,
theme,
size,
layout,
position,
@@ -61,7 +73,14 @@ export function ContextMenu({
data-testid={testId}
ref={ref}
className={cn(
contextMenuVariants({ size, layout, position, spacing, alignment }),
contextMenuVariants({
theme,
size,
layout,
position,
spacing,
alignment,
}),
className,
)}
>

View File

@@ -27,7 +27,7 @@ export function DropdownMenu({
<div
className={cn(
"absolute z-10 w-full mt-1",
"bg-[#454545] border border-[#727987] rounded-lg",
"bg-[#1F1F1F] border border-[#242424] rounded-lg",
"max-h-60 overflow-auto",
!isOpen && "hidden",
)}

View File

@@ -18,6 +18,7 @@ interface DropdownProps {
defaultValue?: DropdownOption;
onChange?: (item: DropdownOption | null) => void;
testId?: string;
className?: string;
}
export function Dropdown({
@@ -30,6 +31,7 @@ export function Dropdown({
defaultValue,
onChange,
testId,
className,
}: DropdownProps) {
const [inputValue, setInputValue] = useState(defaultValue?.label ?? "");
const [searchTerm, setSearchTerm] = useState("");
@@ -98,6 +100,7 @@ export function Dropdown({
"bg-tertiary border border-[#717888] rounded w-full p-2",
"flex items-center gap-2",
isDisabled && "cursor-not-allowed opacity-60",
className,
)}
>
<DropdownInput

View File

@@ -20,4 +20,5 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
export const ENABLE_SANDBOX_GROUPING = () =>
loadFeatureFlag("SANDBOX_GROUPING");
export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY");
export const ENABLE_PROJ_USER_JOURNEY = () =>
loadFeatureFlag("PROJ_USER_JOURNEY");