diff --git a/frontend/__tests__/components/context-menu/context-menu-container.test.tsx b/frontend/__tests__/components/context-menu/context-menu-container.test.tsx new file mode 100644 index 0000000000..29e74021e2 --- /dev/null +++ b/frontend/__tests__/components/context-menu/context-menu-container.test.tsx @@ -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( + +
Child 1
+
Child 2
+
, + ); + + expect(screen.getByTestId("child-1")).toBeInTheDocument(); + expect(screen.getByTestId("child-2")).toBeInTheDocument(); + }); + + it("should apply consistent base styling", () => { + render( + +
Content
+
, + ); + + 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( + +
Content
+
, + ); + + await user.click(document.body); + expect(onCloseMock).toHaveBeenCalledOnce(); + }); + + it("should render children in a flex row layout", () => { + render( + +
Child 1
+
Child 2
+
, + ); + + 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( + +
Content
+
, + ); + + const container = screen.getByTestId("test-container"); + expect(container).toHaveClass("custom-class"); + }); +}); diff --git a/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx b/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx new file mode 100644 index 0000000000..ba80c81410 --- /dev/null +++ b/frontend/__tests__/components/context-menu/context-menu-cta.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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"); + }); +}); diff --git a/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx index 2568601423..8471d87c3e 100644 --- a/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx +++ b/frontend/__tests__/components/features/device-verify/enterprise-banner.test.tsx @@ -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(); @@ -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(); diff --git a/frontend/__tests__/components/features/user/user-context-menu.test.tsx b/frontend/__tests__/components/features/user/user-context-menu.test.tsx index f69de4c0d3..635f66e645 100644 --- a/frontend/__tests__/components/features/user/user-context-menu.test.tsx +++ b/frontend/__tests__/components/features/user/user-context-menu.test.tsx @@ -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; 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(); + }); + }); }); diff --git a/frontend/__tests__/routes/device-verify.test.tsx b/frontend/__tests__/routes/device-verify.test.tsx index 47773ddbf5..289abc4643 100644 --- a/frontend/__tests__/routes/device-verify.test.tsx +++ b/frontend/__tests__/routes/device-verify.test.tsx @@ -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, diff --git a/frontend/src/components/features/context-menu/context-menu-container.tsx b/frontend/src/components/features/context-menu/context-menu-container.tsx new file mode 100644 index 0000000000..fe4ce7835c --- /dev/null +++ b/frontend/src/components/features/context-menu/context-menu-container.tsx @@ -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(onClose); + + return ( +
+
{children}
+
+ ); +} diff --git a/frontend/src/components/features/context-menu/context-menu-cta.tsx b/frontend/src/components/features/context-menu/context-menu-cta.tsx new file mode 100644 index 0000000000..74cf91683b --- /dev/null +++ b/frontend/src/components/features/context-menu/context-menu-cta.tsx @@ -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 ( + +
+ + + {t(I18nKey.CTA$ENTERPRISE_TITLE)} + + + {t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)} + + + +
+
+ ); +} diff --git a/frontend/src/components/features/device-verify/enterprise-banner.tsx b/frontend/src/components/features/device-verify/enterprise-banner.tsx index 7746bac48d..1bae456efd 100644 --- a/frontend/src/components/features/device-verify/enterprise-banner.tsx +++ b/frontend/src/components/features/device-verify/enterprise-banner.tsx @@ -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; } diff --git a/frontend/src/components/features/home/new-conversation/new-conversation.tsx b/frontend/src/components/features/home/new-conversation/new-conversation.tsx index 0182ee0c10..75263f0687 100644 --- a/frontend/src/components/features/home/new-conversation/new-conversation.tsx +++ b/frontend/src/components/features/home/new-conversation/new-conversation.tsx @@ -10,7 +10,7 @@ export function NewConversation() { const { t } = useTranslation(); return ( - + }> {t(I18nKey.COMMON$START_FROM_SCRATCH)} diff --git a/frontend/src/components/features/org/org-selector.tsx b/frontend/src/components/features/org/org-selector.tsx index d5b982a112..b32f379e96 100644 --- a/frontend/src/components/features/org/org-selector.tsx +++ b/frontend/src/components/features/org/org-selector.tsx @@ -56,6 +56,7 @@ export function OrgSelector() { label: getOrgDisplayName(org), })) || [] } + className="bg-[#1F1F1F66] border-[#242424]" /> ); } diff --git a/frontend/src/components/features/user/user-context-menu.tsx b/frontend/src/components/features/user/user-context-menu.tsx index c78d6c15f3..b9094cc6d3 100644 --- a/frontend/src/components/features/user/user-context-menu.tsx +++ b/frontend/src/components/features/user/user-context-menu.tsx @@ -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(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 ( -
-

- {t(I18nKey.ORG$ACCOUNT)} -

+ +
+

+ {t(I18nKey.ORG$ACCOUNT)} +

-
- {!shouldHideSelector && ( -
- -
- )} +
+ {!shouldHideSelector && ( +
+ +
+ )} + + {!isMember && !isPersonalOrg && ( +
+ + + {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} + + + + + + + {t(I18nKey.COMMON$ORGANIZATION)} + + + + {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} + + +
+ )} - {!isMember && !isPersonalOrg && (
- - - {t(I18nKey.ORG$INVITE_ORG_MEMBERS)} - - - - - - - {t(I18nKey.COMMON$ORGANIZATION)} - - - - {t(I18nKey.ORG$ORGANIZATION_MEMBERS)} - - + {navItems.map((item) => ( + + {React.cloneElement(item.icon, { + className: "text-white", + width: 16, + height: 16, + } as React.SVGProps)} + {t(item.text)} + + ))}
- )} -
-
+ + {showCta && } + ); } diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts index 70fc98f810..a81aa46ccc 100644 --- a/frontend/src/hooks/use-tracking.ts +++ b/frontend/src/hooks/use-tracking.ts @@ -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, }; }; diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 9d85c9528b..81b1301ace 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index e1cbb821c1..da7b4f391f 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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": "Дізнатися більше" } } diff --git a/frontend/src/icons/stacked.svg b/frontend/src/icons/stacked.svg new file mode 100644 index 0000000000..3f15d38765 --- /dev/null +++ b/frontend/src/icons/stacked.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/routes/device-verify.tsx b/frontend/src/routes/device-verify.tsx index aabc94e544..b05db95240 100644 --- a/frontend/src/routes/device-verify.tsx +++ b/frontend/src/routes/device-verify.tsx @@ -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"); diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css index eee31d1d16..8cbacea7ef 100644 --- a/frontend/src/tailwind.css +++ b/frontend/src/tailwind.css @@ -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); +} diff --git a/frontend/src/ui/card.tsx b/frontend/src/ui/card.tsx index 519cbd5730..ea420c4296 100644 --- a/frontend/src/ui/card.tsx +++ b/frontend/src/ui/card.tsx @@ -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 { - 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 (
{children}
diff --git a/frontend/src/ui/context-menu.tsx b/frontend/src/ui/context-menu.tsx index 1a6af5cbcd..4d31d629bb 100644 --- a/frontend/src/ui/context-menu.tsx +++ b/frontend/src/ui/context-menu.tsx @@ -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; testId?: string; children: React.ReactNode; className?: React.HTMLAttributes["className"]; + theme?: VariantProps["theme"]; size?: VariantProps["size"]; layout?: VariantProps["layout"]; position?: VariantProps["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, )} > diff --git a/frontend/src/ui/dropdown/dropdown-menu.tsx b/frontend/src/ui/dropdown/dropdown-menu.tsx index 7880089566..80bf7081a2 100644 --- a/frontend/src/ui/dropdown/dropdown-menu.tsx +++ b/frontend/src/ui/dropdown/dropdown-menu.tsx @@ -27,7 +27,7 @@ export function DropdownMenu({
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, )} > 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");