From 1ee71c1f20882dd4707e9e5a1cede47be56fcd68 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:40:08 +0200 Subject: [PATCH] feat(ui): redesign app sidebar navigation (#11994) --- ui/app/(prowler)/lighthouse/page.tsx | 8 +- ui/app/(prowler)/page.tsx | 8 +- ui/changelog.d/sidebar-redesign.changed.md | 1 + .../app-sidebar/app-sidebar-content.test.tsx | 126 +++++++ .../app-sidebar/app-sidebar-content.tsx | 68 ++++ .../app-sidebar-mode-store.test.ts | 73 ++++ .../app-sidebar/app-sidebar-mode-store.ts | 54 +++ .../app-sidebar-mode-sync.test.tsx | 20 ++ .../app-sidebar/app-sidebar-mode-sync.tsx | 20 ++ .../app-sidebar/app-sidebar-mode-toggle.tsx | 115 ++++++ .../layout/app-sidebar/app-sidebar.tsx | 10 + ui/components/layout/app-sidebar/index.ts | 5 + .../layout/app-sidebar/launch-scan-action.tsx | 57 +++ .../app-sidebar/mobile-app-sidebar.test.tsx | 62 ++++ .../layout/app-sidebar/mobile-app-sidebar.tsx | 63 ++++ .../app-sidebar/navigation-config.test.ts | 335 ++++++++++++++++++ .../layout/app-sidebar/navigation-config.ts | 325 +++++++++++++++++ .../layout/app-sidebar/sidebar-footer.tsx | 63 ++++ .../app-sidebar/sidebar-navigation.test.tsx | 196 ++++++++++ .../layout/app-sidebar/sidebar-navigation.tsx | 288 +++++++++++++++ ui/components/layout/app-sidebar/types.ts | 71 ++++ ui/components/layout/index.ts | 2 +- .../layout/main-layout/main-layout.test.tsx | 17 +- .../layout/main-layout/main-layout.tsx | 46 +-- .../layout/nav-bar/navbar-client.test.tsx | 12 +- .../layout/nav-bar/navbar-client.tsx | 10 +- .../layout/sidebar/collapsible-menu.tsx | 103 ------ ui/components/layout/sidebar/index.ts | 7 - ui/components/layout/sidebar/menu-item.tsx | 82 ----- ui/components/layout/sidebar/menu.test.tsx | 302 ---------------- ui/components/layout/sidebar/menu.tsx | 271 -------------- .../layout/sidebar/sheet-menu.test.tsx | 35 -- ui/components/layout/sidebar/sheet-menu.tsx | 63 ---- .../layout/sidebar/sidebar-toggle.tsx | 38 -- ui/components/layout/sidebar/sidebar.tsx | 59 --- .../layout/sidebar/submenu-item.test.tsx | 57 --- ui/components/layout/sidebar/submenu-item.tsx | 155 -------- ui/components/shadcn/button/button.tsx | 6 - ui/components/shadcn/index.ts | 1 + .../shadcn/navigation-button/index.ts | 1 + .../navigation-button.test.tsx | 31 ++ .../navigation-button/navigation-button.tsx | 115 ++++++ ui/components/shadcn/sheet/sheet.test.tsx | 31 ++ ui/components/shadcn/sheet/sheet.tsx | 150 ++++---- .../sidebar/navigation-mode-sync.test.tsx | 24 -- .../sidebar/navigation-mode-sync.tsx | 20 -- .../sidebar/navigation-mode-toggle.tsx | 139 -------- ui/components/ui/index.ts | 1 - ui/components/ui/sidebar/index.ts | 3 - ui/hooks/index.ts | 1 - ui/hooks/use-sidebar.ts | 54 --- ui/lib/index.ts | 1 - ui/lib/menu-list.test.ts | 210 ----------- ui/lib/menu-list.ts | 294 --------------- ui/package.json | 6 +- ui/playwright.config.ts | 10 + ui/styles/globals.css | 62 ++++ ui/tests/navigation/navigation-page.ts | 62 ++++ ui/tests/navigation/navigation.md | 36 ++ ui/tests/navigation/navigation.spec.ts | 22 ++ ui/types/components.ts | 62 ---- 61 files changed, 2441 insertions(+), 2128 deletions(-) create mode 100644 ui/changelog.d/sidebar-redesign.changed.md create mode 100644 ui/components/layout/app-sidebar/app-sidebar-content.test.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-content.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-store.ts create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx create mode 100644 ui/components/layout/app-sidebar/app-sidebar.tsx create mode 100644 ui/components/layout/app-sidebar/index.ts create mode 100644 ui/components/layout/app-sidebar/launch-scan-action.tsx create mode 100644 ui/components/layout/app-sidebar/mobile-app-sidebar.test.tsx create mode 100644 ui/components/layout/app-sidebar/mobile-app-sidebar.tsx create mode 100644 ui/components/layout/app-sidebar/navigation-config.test.ts create mode 100644 ui/components/layout/app-sidebar/navigation-config.ts create mode 100644 ui/components/layout/app-sidebar/sidebar-footer.tsx create mode 100644 ui/components/layout/app-sidebar/sidebar-navigation.test.tsx create mode 100644 ui/components/layout/app-sidebar/sidebar-navigation.tsx create mode 100644 ui/components/layout/app-sidebar/types.ts delete mode 100644 ui/components/layout/sidebar/collapsible-menu.tsx delete mode 100644 ui/components/layout/sidebar/index.ts delete mode 100644 ui/components/layout/sidebar/menu-item.tsx delete mode 100644 ui/components/layout/sidebar/menu.test.tsx delete mode 100644 ui/components/layout/sidebar/menu.tsx delete mode 100644 ui/components/layout/sidebar/sheet-menu.test.tsx delete mode 100644 ui/components/layout/sidebar/sheet-menu.tsx delete mode 100644 ui/components/layout/sidebar/sidebar-toggle.tsx delete mode 100644 ui/components/layout/sidebar/sidebar.tsx delete mode 100644 ui/components/layout/sidebar/submenu-item.test.tsx delete mode 100644 ui/components/layout/sidebar/submenu-item.tsx create mode 100644 ui/components/shadcn/navigation-button/index.ts create mode 100644 ui/components/shadcn/navigation-button/navigation-button.test.tsx create mode 100644 ui/components/shadcn/navigation-button/navigation-button.tsx create mode 100644 ui/components/shadcn/sheet/sheet.test.tsx delete mode 100644 ui/components/sidebar/navigation-mode-sync.test.tsx delete mode 100644 ui/components/sidebar/navigation-mode-sync.tsx delete mode 100644 ui/components/sidebar/navigation-mode-toggle.tsx delete mode 100644 ui/components/ui/sidebar/index.ts delete mode 100644 ui/hooks/use-sidebar.ts delete mode 100644 ui/lib/menu-list.test.ts delete mode 100644 ui/lib/menu-list.ts create mode 100644 ui/tests/navigation/navigation-page.ts create mode 100644 ui/tests/navigation/navigation.md create mode 100644 ui/tests/navigation/navigation.spec.ts diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index cd3ad44ccf..625aaf795a 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -13,10 +13,12 @@ import { import { LighthouseV2ChatPage } from "@/app/(prowler)/lighthouse/_components/chat"; import { loadLighthouseV2ConnectedModels } from "@/app/(prowler)/lighthouse/_lib/model-loading"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { + APP_SIDEBAR_MODE, + AppSidebarModeSync, +} from "@/components/layout/app-sidebar"; import { Chat } from "@/components/lighthouse-v1"; import { ContentLayout } from "@/components/shadcn/content-layout"; -import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync"; -import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar"; import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import { isCloud } from "@/lib/shared/env"; @@ -78,7 +80,7 @@ export default async function AIChatbot({ return ( }> - + {/* [contain:layout] traps streamdown's fixed fullscreen overlay inside the chat area so it never covers the sidebar or navbar. */}
diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 5111baf656..ff70583e19 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -5,9 +5,11 @@ import { getAllProviders } from "@/actions/providers"; import { getLighthouseV2Configurations } from "@/app/(prowler)/lighthouse/_actions"; import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { ProviderGroupSelector } from "@/components/filters/provider-group-selector"; +import { + APP_SIDEBAR_MODE, + AppSidebarModeSync, +} from "@/components/layout/app-sidebar"; import { ContentLayout } from "@/components/shadcn/content-layout"; -import { SidebarNavigationModeSync } from "@/components/sidebar/navigation-mode-sync"; -import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar"; import { isCloud } from "@/lib/shared/env"; import { SearchParamsProps } from "@/types"; @@ -55,7 +57,7 @@ export default async function Home({ return ( - +
diff --git a/ui/changelog.d/sidebar-redesign.changed.md b/ui/changelog.d/sidebar-redesign.changed.md new file mode 100644 index 0000000000..9d45d8ffdf --- /dev/null +++ b/ui/changelog.d/sidebar-redesign.changed.md @@ -0,0 +1 @@ +Sidebar navigation with grouped sections, clearer active states, and a responsive mobile overlay diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx new file mode 100644 index 0000000000..54dedad07c --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { AppSidebarContent } from "./app-sidebar-content"; +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +const { + openCloudUpgradeMock, + openLaunchScanModalMock, + pathnameValue, + pushMock, +} = vi.hoisted(() => ({ + openCloudUpgradeMock: vi.fn(), + openLaunchScanModalMock: vi.fn(), + pathnameValue: { current: "/findings" }, + pushMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameValue.current, + useRouter: () => ({ push: pushMock }), +})); + +vi.mock("@/hooks", () => ({ + useAuth: () => ({ permissions: {} }), +})); + +vi.mock("@/hooks/use-runtime-config", () => ({ + useRuntimeConfig: () => ({ apiDocsUrl: "https://local.example/docs" }), +})); + +vi.mock("@/store", () => ({ + useScansStore: ( + selector: (state: { + openLaunchScanModal: typeof openLaunchScanModalMock; + }) => unknown, + ) => selector({ openLaunchScanModal: openLaunchScanModalMock }), + useCloudUpgradeStore: ( + selector: (state: { + openCloudUpgrade: typeof openCloudUpgradeMock; + }) => unknown, + ) => selector({ openCloudUpgrade: openCloudUpgradeMock }), +})); + +vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({ + LighthouseV2SidebarChat: () =>
, +})); + +describe("AppSidebarContent", () => { + beforeEach(() => { + pathnameValue.current = "/findings"; + pushMock.mockClear(); + openCloudUpgradeMock.mockClear(); + openLaunchScanModalMock.mockClear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("shares the brand, Launch Scan action and Local Server Cloud affordances", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "5.8.0"); + const user = userEvent.setup(); + + // When + render(); + + // Then + const homeLink = screen.getByRole("link", { name: "Prowler home" }); + expect(homeLink).toBeVisible(); + expect(screen.getByRole("link", { name: "Launch Scan" })).toHaveAttribute( + "href", + "/scans?launchScan=true", + ); + expect(screen.getByText("5.8.0")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Chat" })); + expect(openCloudUpgradeMock).toHaveBeenCalledWith( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + undefined, + ); + expect(screen.getAllByText("Cloud").length).toBeGreaterThan(0); + }); + + it("keeps the existing Lighthouse chat sidebar in Cloud Chat mode", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + + // When + render(); + + // Then + expect(screen.getByTestId("lighthouse-chat-sidebar")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Service status" }), + ).toHaveAttribute("href", "https://status.prowler.com"); + expect( + screen.queryByText("All systems operational"), + ).not.toBeInTheDocument(); + }); + + it("opens the current scan modal instead of navigating from the scans route", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + pathnameValue.current = "/scans"; + const user = userEvent.setup(); + + // When + render(); + await user.click(screen.getByRole("button", { name: "Launch Scan" })); + + // Then + expect(openLaunchScanModalMock).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("link", { name: "Launch Scan" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.tsx new file mode 100644 index 0000000000..c32a7d5048 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-content.tsx @@ -0,0 +1,68 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { LighthouseV2SidebarChat } from "@/app/(prowler)/lighthouse/_components/navigation"; +import { ProwlerBrand } from "@/components/icons"; +import { useAuth } from "@/hooks"; +import { useRuntimeConfig } from "@/hooks/use-runtime-config"; +import { isCloud } from "@/lib/shared/env"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeToggle } from "./app-sidebar-mode-toggle"; +import { LaunchScanAction } from "./launch-scan-action"; +import { getNavigationConfig } from "./navigation-config"; +import { SidebarFooter } from "./sidebar-footer"; +import { SidebarNavigation } from "./sidebar-navigation"; +import { APP_SIDEBAR_MODE, type AppSidebarSelectionHandler } from "./types"; + +interface AppSidebarContentProps { + onSelect?: AppSidebarSelectionHandler; +} + +export function AppSidebarContent({ onSelect }: AppSidebarContentProps) { + const pathname = usePathname(); + const { permissions } = useAuth(); + const { apiDocsUrl } = useRuntimeConfig(); + const mode = useAppSidebarMode((state) => state.mode); + const isCloudEnvironment = isCloud(); + const sections = getNavigationConfig({ pathname, apiDocsUrl, permissions }); + const showChat = isCloudEnvironment && mode === APP_SIDEBAR_MODE.CHAT; + + return ( +
+
+ + + +
+ +
+ + +
+ +
+ {showChat ? ( + + ) : ( + + )} +
+ + +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts new file mode 100644 index 0000000000..ba85902736 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + migrateAppSidebarState, + useAppSidebarMode, +} from "./app-sidebar-mode-store"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("app sidebar mode store", () => { + beforeEach(() => { + localStorage.clear(); + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("keeps the persisted chat mode while discarding legacy sidebar state", () => { + // Given + const legacyState = { + isOpen: false, + isHover: true, + navigationMode: APP_SIDEBAR_MODE.CHAT, + settings: { disabled: false, isHoverOpen: false }, + }; + + // When + const migrated = migrateAppSidebarState(legacyState); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.CHAT }); + expect(migrated).not.toHaveProperty("isOpen"); + expect(migrated).not.toHaveProperty("settings"); + }); + + it("falls back to browse for an invalid persisted mode", () => { + // Given / When + const migrated = migrateAppSidebarState({ navigationMode: "invalid" }); + + // Then + expect(migrated).toEqual({ mode: APP_SIDEBAR_MODE.BROWSE }); + }); + + it("rehydrates the legacy payload under the existing sidebar key", async () => { + // Given + localStorage.setItem( + "sidebar", + JSON.stringify({ + state: { + navigationMode: APP_SIDEBAR_MODE.CHAT, + isOpen: false, + isHover: true, + settings: { disabled: true }, + }, + version: 0, + }), + ); + + // When + await useAppSidebarMode.persist.rehydrate(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + expect(useAppSidebarMode.getState()).not.toEqual( + expect.objectContaining({ isOpen: expect.anything() }), + ); + }); + + it("updates the current navigation mode", () => { + // Given / When + useAppSidebarMode.getState().setMode(APP_SIDEBAR_MODE.CHAT); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.CHAT); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts new file mode 100644 index 0000000000..fc076c0296 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-store.ts @@ -0,0 +1,54 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { APP_SIDEBAR_MODE, type AppSidebarMode } from "./types"; + +interface PersistedAppSidebarState { + mode: AppSidebarMode; +} + +interface AppSidebarModeStore extends PersistedAppSidebarState { + setMode: (mode: AppSidebarMode) => void; +} + +function isAppSidebarMode(value: unknown): value is AppSidebarMode { + return Object.values(APP_SIDEBAR_MODE).some((mode) => mode === value); +} + +export function migrateAppSidebarState( + persistedState: unknown, +): PersistedAppSidebarState { + if (typeof persistedState !== "object" || persistedState === null) { + return { mode: APP_SIDEBAR_MODE.BROWSE }; + } + + if ("mode" in persistedState && isAppSidebarMode(persistedState.mode)) { + return { mode: persistedState.mode }; + } + + if ( + "navigationMode" in persistedState && + isAppSidebarMode(persistedState.navigationMode) + ) { + return { mode: persistedState.navigationMode }; + } + + return { mode: APP_SIDEBAR_MODE.BROWSE }; +} + +export const useAppSidebarMode = create()( + persist( + (set) => ({ + mode: APP_SIDEBAR_MODE.BROWSE, + setMode: (mode) => set({ mode }), + }), + { + name: "sidebar", + storage: createJSONStorage(() => localStorage), + merge: (persistedState, currentState) => ({ + ...currentState, + ...migrateAppSidebarState(persistedState), + }), + }, + ), +); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx new file mode 100644 index 0000000000..59caaae738 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.test.tsx @@ -0,0 +1,20 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +import { APP_SIDEBAR_MODE } from "./types"; + +describe("AppSidebarModeSync", () => { + beforeEach(() => { + useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.CHAT }); + }); + + it("restores the requested sidebar mode when a route mounts", () => { + // Given / When + render(); + + // Then + expect(useAppSidebarMode.getState().mode).toBe(APP_SIDEBAR_MODE.BROWSE); + }); +}); diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx new file mode 100644 index 0000000000..c97d84a14d --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-sync.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useMountEffect } from "@/hooks/use-mount-effect"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import type { AppSidebarMode } from "./types"; + +interface AppSidebarModeSyncProps { + mode: AppSidebarMode; +} + +export function AppSidebarModeSync({ mode }: AppSidebarModeSyncProps) { + const setMode = useAppSidebarMode((state) => state.setMode); + + useMountEffect(() => { + setMode(mode); + }); + + return null; +} diff --git a/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx new file mode 100644 index 0000000000..0631d09dbf --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar-mode-toggle.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Home } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { NavigationButton } from "@/components/shadcn/navigation-button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { useAppSidebarMode } from "./app-sidebar-mode-store"; +import { + APP_SIDEBAR_MODE, + type AppSidebarMode, + type AppSidebarSelectionHandler, +} from "./types"; + +interface AppSidebarModeToggleProps { + chatEnabled: boolean; + onSelect?: AppSidebarSelectionHandler; +} + +const MODES = [ + { + value: APP_SIDEBAR_MODE.BROWSE, + label: "Home", + icon: Home, + }, + { + value: APP_SIDEBAR_MODE.CHAT, + label: "Chat", + icon: LighthouseIcon, + }, +] as const; + +export function AppSidebarModeToggle({ + chatEnabled, + onSelect, +}: AppSidebarModeToggleProps) { + const router = useRouter(); + const mode = useAppSidebarMode((state) => state.mode); + const setMode = useAppSidebarMode((state) => state.setMode); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + const selectMode = (nextMode: AppSidebarMode) => { + const isChatUpsell = nextMode === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + + if (isChatUpsell) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } + + setMode(nextMode); + onSelect?.(); + + if (nextMode === APP_SIDEBAR_MODE.CHAT) { + router.push("/lighthouse"); + } + }; + + return ( +
+ {MODES.map((item) => { + const Icon = item.icon; + const isActive = item.value === mode; + const isCloudUpsell = + item.value === APP_SIDEBAR_MODE.CHAT && !chatEnabled; + const button = ( + selectMode(item.value)} + > + + ); + + if (!isCloudUpsell) return button; + + return ( + + {button} + + Available in Prowler Cloud + + + ); + })} +
+ ); +} diff --git a/ui/components/layout/app-sidebar/app-sidebar.tsx b/ui/components/layout/app-sidebar/app-sidebar.tsx new file mode 100644 index 0000000000..6864cd64a9 --- /dev/null +++ b/ui/components/layout/app-sidebar/app-sidebar.tsx @@ -0,0 +1,10 @@ +import { AppSidebarContent } from "./app-sidebar-content"; + +export function AppSidebar() { + return ( + + ); +} diff --git a/ui/components/layout/app-sidebar/index.ts b/ui/components/layout/app-sidebar/index.ts new file mode 100644 index 0000000000..5ee953a19d --- /dev/null +++ b/ui/components/layout/app-sidebar/index.ts @@ -0,0 +1,5 @@ +export { AppSidebar } from "./app-sidebar"; +export { AppSidebarModeSync } from "./app-sidebar-mode-sync"; +export { MobileAppSidebar } from "./mobile-app-sidebar"; +export type { AppSidebarMode } from "./types"; +export { APP_SIDEBAR_MODE } from "./types"; diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx new file mode 100644 index 0000000000..1da429811f --- /dev/null +++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ScanLine } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { Button } from "@/components/shadcn/button/button"; +import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation"; +import { useScansStore } from "@/store"; + +import type { AppSidebarSelectionHandler } from "./types"; + +interface LaunchScanActionProps { + onSelect?: AppSidebarSelectionHandler; +} + +function LaunchScanContent() { + return ( + <> +