From bbf90e8e2c4192a014bf67017f7f714d8fe1c97c Mon Sep 17 00:00:00 2001 From: evren <158852680+evrendom@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:57:01 +0200 Subject: [PATCH 1/3] refactor: extract web app architecture entrypoints --- apps/web/src/App.tsx | 294 +++--------------- apps/web/src/app/AppRouter.tsx | 55 ++++ .../src/app/bootstrap/AppLoadingScreen.tsx | 11 + apps/web/src/app/providers/AppProviders.tsx | 15 + .../tracking/ProductAnalyticsSessionSync.tsx | 35 +++ .../src/features/auth/AuthenticatedApp.tsx | 9 + .../features/auth/DeviceAuthorizationApp.tsx | 132 ++++++++ apps/web/src/features/auth/GuestApp.tsx | 24 ++ .../src/features/auth/ResetPasswordApp.tsx | 15 + .../web/src/features/auth/auth-route-utils.ts | 57 ++++ apps/web/src/main.tsx | 15 +- 11 files changed, 398 insertions(+), 264 deletions(-) create mode 100644 apps/web/src/app/AppRouter.tsx create mode 100644 apps/web/src/app/bootstrap/AppLoadingScreen.tsx create mode 100644 apps/web/src/app/providers/AppProviders.tsx create mode 100644 apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx create mode 100644 apps/web/src/features/auth/AuthenticatedApp.tsx create mode 100644 apps/web/src/features/auth/DeviceAuthorizationApp.tsx create mode 100644 apps/web/src/features/auth/GuestApp.tsx create mode 100644 apps/web/src/features/auth/ResetPasswordApp.tsx create mode 100644 apps/web/src/features/auth/auth-route-utils.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7cbc042f..0a057b77 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,280 +1,68 @@ -import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; -import { LoginForm } from "./components/auth/login-form"; -import { ResetPasswordForm } from "./components/auth/reset-password-form"; -import { SignupForm } from "./components/auth/signup-form"; -import { Button } from "./components/ui/button"; -import { useAnalyticsTracking } from "./hooks/useDashboardAnalytics"; -import { DashboardLayout } from "./layouts/DashboardLayout"; -import { authClient } from "./lib/auth-client"; +import { useLocation } from "react-router-dom"; +import { AppLoadingScreen } from "@/app/bootstrap/AppLoadingScreen"; +import { ProductAnalyticsSessionSync } from "@/features/analytics/tracking/ProductAnalyticsSessionSync"; +import { AuthenticatedApp } from "@/features/auth/AuthenticatedApp"; import { - identifyProductAnalyticsUser, - resetProductAnalytics, -} from "./lib/product-analytics"; -import { AcceptInvitationPage } from "./pages/AcceptInvitationPage"; -import { AdminPage } from "./pages/dashboard/AdminPage"; -import { CreateOrgPage } from "./pages/dashboard/CreateOrgPage"; -import { DeveloperDetailPage } from "./pages/dashboard/DeveloperDetailPage"; -import { DevelopersListPage } from "./pages/dashboard/DevelopersListPage"; -import { ErrorsPage } from "./pages/dashboard/ErrorsPage"; -import { InvitationsPage } from "./pages/dashboard/InvitationsPage"; -import { LearningsPage } from "./pages/dashboard/LearningsPage"; -import { OrganizationPage } from "./pages/dashboard/OrganizationPage"; -import { OverviewPage } from "./pages/dashboard/OverviewPage"; -import { ProfilePage } from "./pages/dashboard/ProfilePage"; -import { ProjectDetailPage } from "./pages/dashboard/ProjectDetailPage"; -import { ProjectsListPage } from "./pages/dashboard/ProjectsListPage"; -import { ROIPage } from "./pages/dashboard/ROIPage"; -import { SessionDetailPage } from "./pages/dashboard/SessionDetailPage"; -import { SessionsListPage } from "./pages/dashboard/SessionsListPage"; - -type Page = "login" | "signup"; - -function isResetPasswordPath() { - return window.location.pathname === "/reset-password"; -} - -function getDeviceUserCode(): string | null { - const params = new URLSearchParams(window.location.search); - return params.get("user_code"); -} - -function getValidRedirect(): string | null { - const params = new URLSearchParams(window.location.search); - const redirect = params.get("redirect"); - if (!redirect) return null; - if (!redirect.startsWith("/") || redirect.startsWith("//")) return null; - return redirect; -} + getDeviceUserCode, + getValidRedirect, + isResetPasswordPath, +} from "@/features/auth/auth-route-utils"; +import { DeviceAuthorizationApp } from "@/features/auth/DeviceAuthorizationApp"; +import { GuestApp } from "@/features/auth/GuestApp"; +import { ResetPasswordApp } from "@/features/auth/ResetPasswordApp"; +import { authClient } from "./lib/auth-client"; function App() { + const location = useLocation(); const { data: session, isPending } = authClient.useSession(); - const { trackAuthenticationAction } = useAnalyticsTracking({ - pageName: "device_login", - }); - const [page, setPage] = useState("login"); - const [deviceProcessing, setDeviceProcessing] = useState(false); - const [deviceApproved, setDeviceApproved] = useState(false); - const [deviceDenied, setDeviceDenied] = useState(false); - const [deviceError, setDeviceError] = useState(null); - const deviceUserCode = getDeviceUserCode(); - const { resolvedTheme } = useTheme(); - const logoSrc = - resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; - - useEffect(() => { - const userId = - session?.user && - "id" in session.user && - typeof session.user.id === "string" - ? session.user.id - : null; - const email = - session?.user && - "email" in session.user && - typeof session.user.email === "string" - ? session.user.email - : undefined; - const name = - session?.user && - "name" in session.user && - typeof session.user.name === "string" - ? session.user.name - : undefined; - - if (userId) { - identifyProductAnalyticsUser(userId, { - email, - name, - }); - return; - } - - resetProductAnalytics(); - }, [session]); - - async function submitDeviceDecision(action: "approve" | "deny") { - if (!deviceUserCode || deviceProcessing) return; - const userId = - session?.user && - "id" in session.user && - typeof session.user.id === "string" - ? session.user.id - : undefined; - trackAuthenticationAction({ - actionName: - action === "approve" ? "approve_device_login" : "deny_device_login", - sourceComponent: "device_login", - authMethod: "device_code", - targetId: deviceUserCode, - userId, - }); - setDeviceProcessing(true); - setDeviceError(null); - try { - const response = await fetch(`/api/auth/device/${action}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userCode: deviceUserCode }), - }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error_description?: string; - message?: string; - } | null; - throw new Error( - body?.error_description ?? - body?.message ?? - `Failed to ${action} CLI device login`, - ); - } - if (action === "approve") { - setDeviceApproved(true); - } else { - setDeviceDenied(true); - } - } catch (err) { - setDeviceError( - err instanceof Error ? err.message : "Failed to process device login", - ); - } finally { - setDeviceProcessing(false); - } - } + const deviceUserCode = getDeviceUserCode(location.search); + const rootRedirectTarget = getValidRedirect(location.search); if (isPending) { return ( -
-

Loading...

-
+ <> + + + ); } if (deviceUserCode) { - if (deviceProcessing) { - return ( -
-

Processing CLI login...

-
- ); - } - - if (deviceApproved) { - return ( -
-

CLI login approved

-

- Return to your terminal to continue. -

-
- ); - } - - if (deviceDenied) { - return ( -
-

CLI login denied

-

- This authorization request was not approved. -

-
- ); - } - - if (!session) { - return ( -
- Rudel - {page === "login" ? ( - setPage("signup")} /> - ) : ( - setPage("login")} /> - )} -
- ); - } - return ( -
-

Authorize CLI login

-

- User code: {deviceUserCode} -

- {deviceError ? ( -

{deviceError}

- ) : ( -

- Approve this request only if it was initiated by you from the CLI. -

- )} -
- - -
-
+ <> + + + ); } - if (!session && isResetPasswordPath()) { + if (session) { return ( -
- Rudel - (window.location.href = "/")} /> -
+ <> + + + ); } - if (!session) { + if (isResetPasswordPath(location.pathname)) { return ( -
- Rudel - {page === "login" ? ( - setPage("signup")} /> - ) : ( - setPage("login")} /> - )} -
+ <> + + + ); } return ( - - } - /> - } - /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - } /> - + <> + + + ); } diff --git a/apps/web/src/app/AppRouter.tsx b/apps/web/src/app/AppRouter.tsx new file mode 100644 index 00000000..cbcf6a69 --- /dev/null +++ b/apps/web/src/app/AppRouter.tsx @@ -0,0 +1,55 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { DashboardLayout } from "@/layouts/DashboardLayout"; +import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; +import { AdminPage } from "@/pages/dashboard/AdminPage"; +import { CreateOrgPage } from "@/pages/dashboard/CreateOrgPage"; +import { DeveloperDetailPage } from "@/pages/dashboard/DeveloperDetailPage"; +import { DevelopersListPage } from "@/pages/dashboard/DevelopersListPage"; +import { ErrorsPage } from "@/pages/dashboard/ErrorsPage"; +import { InvitationsPage } from "@/pages/dashboard/InvitationsPage"; +import { LearningsPage } from "@/pages/dashboard/LearningsPage"; +import { OrganizationPage } from "@/pages/dashboard/OrganizationPage"; +import { OverviewPage } from "@/pages/dashboard/OverviewPage"; +import { ProfilePage } from "@/pages/dashboard/ProfilePage"; +import { ProjectDetailPage } from "@/pages/dashboard/ProjectDetailPage"; +import { ProjectsListPage } from "@/pages/dashboard/ProjectsListPage"; +import { ROIPage } from "@/pages/dashboard/ROIPage"; +import { SessionDetailPage } from "@/pages/dashboard/SessionDetailPage"; +import { SessionsListPage } from "@/pages/dashboard/SessionsListPage"; + +export function AppRouter({ + rootRedirectTarget, +}: { + rootRedirectTarget: string | null; +}) { + return ( + + } + /> + } + /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/web/src/app/bootstrap/AppLoadingScreen.tsx b/apps/web/src/app/bootstrap/AppLoadingScreen.tsx new file mode 100644 index 00000000..cc770cf9 --- /dev/null +++ b/apps/web/src/app/bootstrap/AppLoadingScreen.tsx @@ -0,0 +1,11 @@ +export function AppLoadingScreen({ + message = "Loading...", +}: { + message?: string; +}) { + return ( +
+

{message}

+
+ ); +} diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx new file mode 100644 index 00000000..bbd55696 --- /dev/null +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -0,0 +1,15 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { queryClient } from "@/lib/query-client"; +import { ThemeProvider } from "@/providers/ThemeProvider"; + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx b/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx new file mode 100644 index 00000000..85b1c9f8 --- /dev/null +++ b/apps/web/src/features/analytics/tracking/ProductAnalyticsSessionSync.tsx @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import { + type AppSession, + getSessionUserEmail, + getSessionUserId, + getSessionUserName, +} from "@/features/auth/auth-route-utils"; +import { + identifyProductAnalyticsUser, + resetProductAnalytics, +} from "@/lib/product-analytics"; + +export function ProductAnalyticsSessionSync({ + session, +}: { + session: AppSession | null | undefined; +}) { + const userId = getSessionUserId(session); + const email = getSessionUserEmail(session); + const name = getSessionUserName(session); + + useEffect(() => { + if (userId) { + identifyProductAnalyticsUser(userId, { + email, + name, + }); + return; + } + + resetProductAnalytics(); + }, [email, name, userId]); + + return null; +} diff --git a/apps/web/src/features/auth/AuthenticatedApp.tsx b/apps/web/src/features/auth/AuthenticatedApp.tsx new file mode 100644 index 00000000..5da73cd0 --- /dev/null +++ b/apps/web/src/features/auth/AuthenticatedApp.tsx @@ -0,0 +1,9 @@ +import { AppRouter } from "@/app/AppRouter"; + +export function AuthenticatedApp({ + rootRedirectTarget, +}: { + rootRedirectTarget: string | null; +}) { + return ; +} diff --git a/apps/web/src/features/auth/DeviceAuthorizationApp.tsx b/apps/web/src/features/auth/DeviceAuthorizationApp.tsx new file mode 100644 index 00000000..62bda11e --- /dev/null +++ b/apps/web/src/features/auth/DeviceAuthorizationApp.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { AppLoadingScreen } from "@/app/bootstrap/AppLoadingScreen"; +import { Button } from "@/components/ui/button"; +import { + type AppSession, + getSessionUserId, +} from "@/features/auth/auth-route-utils"; +import { GuestApp } from "@/features/auth/GuestApp"; +import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; + +export function DeviceAuthorizationApp({ + deviceUserCode, + session, +}: { + deviceUserCode: string; + session: AppSession | null; +}) { + const { trackAuthenticationAction } = useAnalyticsTracking({ + pageName: "device_login", + }); + const [deviceProcessing, setDeviceProcessing] = useState(false); + const [deviceApproved, setDeviceApproved] = useState(false); + const [deviceDenied, setDeviceDenied] = useState(false); + const [deviceError, setDeviceError] = useState(null); + + async function submitDeviceDecision(action: "approve" | "deny") { + if (deviceProcessing) { + return; + } + + trackAuthenticationAction({ + actionName: + action === "approve" ? "approve_device_login" : "deny_device_login", + sourceComponent: "device_login", + authMethod: "device_code", + targetId: deviceUserCode, + userId: getSessionUserId(session) ?? undefined, + }); + + setDeviceProcessing(true); + setDeviceError(null); + + try { + const response = await fetch(`/api/auth/device/${action}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode: deviceUserCode }), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error_description?: string; + message?: string; + } | null; + + throw new Error( + body?.error_description ?? + body?.message ?? + `Failed to ${action} CLI device login`, + ); + } + + if (action === "approve") { + setDeviceApproved(true); + return; + } + + setDeviceDenied(true); + } catch (error) { + setDeviceError( + error instanceof Error + ? error.message + : "Failed to process device login", + ); + } finally { + setDeviceProcessing(false); + } + } + + if (!session) { + return ; + } + + if (deviceProcessing) { + return ; + } + + if (deviceApproved) { + return ( +
+

CLI login approved

+

+ Return to your terminal to continue. +

+
+ ); + } + + if (deviceDenied) { + return ( +
+

CLI login denied

+

+ This authorization request was not approved. +

+
+ ); + } + + return ( +
+

Authorize CLI login

+

+ User code: {deviceUserCode} +

+ {deviceError ? ( +

{deviceError}

+ ) : ( +

+ Approve this request only if it was initiated by you from the CLI. +

+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/features/auth/GuestApp.tsx b/apps/web/src/features/auth/GuestApp.tsx new file mode 100644 index 00000000..5976096e --- /dev/null +++ b/apps/web/src/features/auth/GuestApp.tsx @@ -0,0 +1,24 @@ +import { useTheme } from "next-themes"; +import { useState } from "react"; +import { LoginForm } from "@/components/auth/login-form"; +import { SignupForm } from "@/components/auth/signup-form"; + +type GuestPage = "login" | "signup"; + +export function GuestApp() { + const [page, setPage] = useState("login"); + const { resolvedTheme } = useTheme(); + const logoSrc = + resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; + + return ( +
+ Rudel + {page === "login" ? ( + setPage("signup")} /> + ) : ( + setPage("login")} /> + )} +
+ ); +} diff --git a/apps/web/src/features/auth/ResetPasswordApp.tsx b/apps/web/src/features/auth/ResetPasswordApp.tsx new file mode 100644 index 00000000..99697b75 --- /dev/null +++ b/apps/web/src/features/auth/ResetPasswordApp.tsx @@ -0,0 +1,15 @@ +import { useTheme } from "next-themes"; +import { ResetPasswordForm } from "@/components/auth/reset-password-form"; + +export function ResetPasswordApp() { + const { resolvedTheme } = useTheme(); + const logoSrc = + resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg"; + + return ( +
+ Rudel + (window.location.href = "/")} /> +
+ ); +} diff --git a/apps/web/src/features/auth/auth-route-utils.ts b/apps/web/src/features/auth/auth-route-utils.ts new file mode 100644 index 00000000..102ef422 --- /dev/null +++ b/apps/web/src/features/auth/auth-route-utils.ts @@ -0,0 +1,57 @@ +import type { authClient } from "@/lib/auth-client"; + +export type AppSession = ReturnType["data"]; + +export function getDeviceUserCode(search?: string): string | null { + const params = new URLSearchParams(search ?? window.location.search); + return params.get("user_code"); +} + +export function isResetPasswordPath(pathname?: string): boolean { + return (pathname ?? window.location.pathname) === "/reset-password"; +} + +export function getValidRedirect(search?: string): string | null { + const params = new URLSearchParams(search ?? window.location.search); + const redirect = params.get("redirect"); + + if (!redirect) { + return null; + } + + if (!redirect.startsWith("/") || redirect.startsWith("//")) { + return null; + } + + return redirect; +} + +export function getSessionUserId( + session: AppSession | null | undefined, +): string | null { + return session?.user && + "id" in session.user && + typeof session.user.id === "string" + ? session.user.id + : null; +} + +export function getSessionUserEmail( + session: AppSession | null | undefined, +): string | undefined { + return session?.user && + "email" in session.user && + typeof session.user.email === "string" + ? session.user.email + : undefined; +} + +export function getSessionUserName( + session: AppSession | null | undefined, +): string | undefined { + return session?.user && + "name" in session.user && + typeof session.user.name === "string" + ? session.user.name + : undefined; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f2f26485..b5065039 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,12 +1,9 @@ -import { QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; +import { AppProviders } from "@/app/providers/AppProviders"; import App from "./App.tsx"; import "./index.css"; import { initProductAnalytics } from "./lib/product-analytics"; -import { queryClient } from "./lib/query-client"; -import { ThemeProvider } from "./providers/ThemeProvider"; function deferProductAnalyticsInit() { if (typeof window === "undefined") { @@ -28,13 +25,9 @@ function deferProductAnalyticsInit() { // biome-ignore lint/style/noNonNullAssertion: root element always exists createRoot(document.getElementById("root")!).render( - - - - - - - + + + , ); From 209552e628d01f726e731f034b610eeba616a219 Mon Sep 17 00:00:00 2001 From: evren <158852680+evrendom@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:03:49 +0200 Subject: [PATCH 2/3] refactor: extract dashboard shell layout --- apps/web/src/app/AppRouter.tsx | 4 +- .../shell/AppShellLayout.tsx} | 18 +- .../shell/components/AppSidebar.tsx} | 394 ++++++++---------- .../shell/components/SiteHeader.tsx} | 31 +- .../features/shell/config/shell-routes.tsx | 91 ++++ 5 files changed, 301 insertions(+), 237 deletions(-) rename apps/web/src/{layouts/DashboardLayout.tsx => features/shell/AppShellLayout.tsx} (54%) rename apps/web/src/{components/analytics/Sidebar.tsx => features/shell/components/AppSidebar.tsx} (51%) rename apps/web/src/{components/analytics/Breadcrumb.tsx => features/shell/components/SiteHeader.tsx} (66%) create mode 100644 apps/web/src/features/shell/config/shell-routes.tsx diff --git a/apps/web/src/app/AppRouter.tsx b/apps/web/src/app/AppRouter.tsx index cbcf6a69..1c083cf4 100644 --- a/apps/web/src/app/AppRouter.tsx +++ b/apps/web/src/app/AppRouter.tsx @@ -1,5 +1,5 @@ import { Navigate, Route, Routes } from "react-router-dom"; -import { DashboardLayout } from "@/layouts/DashboardLayout"; +import { AppShellLayout } from "@/features/shell/AppShellLayout"; import { AcceptInvitationPage } from "@/pages/AcceptInvitationPage"; import { AdminPage } from "@/pages/dashboard/AdminPage"; import { CreateOrgPage } from "@/pages/dashboard/CreateOrgPage"; @@ -32,7 +32,7 @@ export function AppRouter({ path="/invitation/:invitationId" element={} /> - }> + }> } /> } /> } /> diff --git a/apps/web/src/layouts/DashboardLayout.tsx b/apps/web/src/features/shell/AppShellLayout.tsx similarity index 54% rename from apps/web/src/layouts/DashboardLayout.tsx rename to apps/web/src/features/shell/AppShellLayout.tsx index 42857f40..24d58499 100644 --- a/apps/web/src/layouts/DashboardLayout.tsx +++ b/apps/web/src/features/shell/AppShellLayout.tsx @@ -1,13 +1,13 @@ import { Outlet } from "react-router-dom"; import { Toaster } from "sonner"; -import { Breadcrumb } from "../components/analytics/Breadcrumb"; -import { Sidebar } from "../components/analytics/Sidebar"; -import { ChatwootBootstrap } from "../components/support/ChatwootBootstrap"; -import { DateRangeProvider } from "../contexts/DateRangeContext"; -import { FilterProvider } from "../contexts/FilterContext"; -import { OrganizationProvider } from "../contexts/OrganizationContext"; +import { ChatwootBootstrap } from "@/components/support/ChatwootBootstrap"; +import { DateRangeProvider } from "@/contexts/DateRangeContext"; +import { FilterProvider } from "@/contexts/FilterContext"; +import { OrganizationProvider } from "@/contexts/OrganizationContext"; +import { AppSidebar } from "@/features/shell/components/AppSidebar"; +import { SiteHeader } from "@/features/shell/components/SiteHeader"; -export function DashboardLayout() { +export function AppShellLayout() { return ( @@ -15,9 +15,9 @@ export function DashboardLayout() {
- +
- +
diff --git a/apps/web/src/components/analytics/Sidebar.tsx b/apps/web/src/features/shell/components/AppSidebar.tsx similarity index 51% rename from apps/web/src/components/analytics/Sidebar.tsx rename to apps/web/src/features/shell/components/AppSidebar.tsx index b5647581..b7909bc4 100644 --- a/apps/web/src/components/analytics/Sidebar.tsx +++ b/apps/web/src/features/shell/components/AppSidebar.tsx @@ -1,60 +1,48 @@ import { - AlertCircle, - BookOpen, Building2, Check, ChevronsLeft, ChevronsRight, ChevronsUpDown, - Clock, - DollarSign, - FolderKanban, - LayoutDashboard, LogOut, Mail, Plus, Settings, Shield, - UserCircle, } from "lucide-react"; import { useTheme } from "next-themes"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { Link, useLocation } from "react-router-dom"; -import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; -import { useOrganization } from "../../contexts/OrganizationContext"; -import { useUserInvitations } from "../../hooks/useUserInvitations"; -import { authClient, signOut } from "../../lib/auth-client"; -import { getAnalyticsPageName } from "../../lib/product-analytics"; -import { cn } from "../../lib/utils"; -import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { ThemeToggle } from "@/components/analytics/ThemeToggle"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from "../ui/dropdown-menu"; +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "../ui/tooltip"; -import { ThemeToggle } from "./ThemeToggle"; +} from "@/components/ui/tooltip"; +import { useOrganization } from "@/contexts/OrganizationContext"; +import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics"; +import { useUserInvitations } from "@/hooks/useUserInvitations"; +import { authClient, signOut } from "@/lib/auth-client"; +import { getAnalyticsPageName } from "@/lib/product-analytics"; +import { cn } from "@/lib/utils"; +import { + isShellRouteActive, + primaryShellRoutes, + type ShellRouteDefinition, +} from "../config/shell-routes"; -const navigation = [ - { name: "Overview", href: "/dashboard", icon: LayoutDashboard }, - { name: "Developers", href: "/dashboard/developers", icon: UserCircle }, - { name: "Projects", href: "/dashboard/projects", icon: FolderKanban }, - { name: "Sessions", href: "/dashboard/sessions", icon: Clock }, - { name: "Learnings", href: "/dashboard/learnings", icon: BookOpen }, - { name: "Errors", href: "/dashboard/errors", icon: AlertCircle }, - { - name: "ROI Calculator", - href: "/dashboard/roi", - icon: DollarSign, - }, -]; +const ADMIN_ORGANIZATION_ID = ( + import.meta.env.VITE_ADMIN_ORGANIZATION_ID ?? "" +).trim(); function getInitials(name: string) { return name @@ -65,11 +53,68 @@ function getInitials(name: string) { .slice(0, 2); } +function SidebarNavLink({ + badgeLabel, + collapsed, + isActive, + label, + onClick, + to, + icon, +}: { + badgeLabel?: string; + collapsed: boolean; + isActive: boolean; + label: string; + onClick: () => void; + to: string; + icon: ReactNode; +}) { + const link = ( + + + {icon} + {badgeLabel ? ( + + {badgeLabel} + + ) : null} + + {collapsed ? null : ( + {label} + )} + + ); + + if (!collapsed) { + return link; + } + + return ( + + {link} + + {badgeLabel ? `${label} (${badgeLabel})` : label} + + + ); +} + function OrgSwitcher({ collapsed }: { collapsed: boolean }) { const { activeOrg, organizations, switchOrg } = useOrganization(); const { trackNavigation, trackOrganizationAction } = useAnalyticsTracking(); - const handleSelect = async (orgId: string) => { + async function handleSelect(orgId: string) { if (orgId === activeOrg?.id) { return; } @@ -80,8 +125,9 @@ function OrgSwitcher({ collapsed }: { collapsed: boolean }) { sourceComponent: "org_switcher", targetId: orgId, }); + await switchOrg(orgId); - }; + } return ( @@ -89,12 +135,12 @@ function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
-
+ ) : null} + ); } diff --git a/apps/web/src/components/analytics/Breadcrumb.tsx b/apps/web/src/features/shell/components/SiteHeader.tsx similarity index 66% rename from apps/web/src/components/analytics/Breadcrumb.tsx rename to apps/web/src/features/shell/components/SiteHeader.tsx index 52dc56b4..b73fca8b 100644 --- a/apps/web/src/components/analytics/Breadcrumb.tsx +++ b/apps/web/src/features/shell/components/SiteHeader.tsx @@ -14,32 +14,37 @@ const segmentLabels: Record = { errors: "Errors", invitations: "Invitations", profile: "Profile", + organization: "Organization", + admin: "Admin", + new: "Create organization", }; -export function Breadcrumb() { +export function SiteHeader() { const { pathname } = useLocation(); - const segments = pathname.split("/").filter(Boolean); const { trackNavigation } = useAnalyticsTracking(); - const { userMap } = useUserMap(); + const segments = pathname.split("/").filter(Boolean); const crumbs = segments.map((segment, index) => { const href = `/${segments.slice(0, index + 1).join("/")}`; - const prevSegment = index > 0 ? segments[index - 1] : null; - const isDeveloperUserId = prevSegment === "developers"; - const label = isDeveloperUserId + const previousSegment = index > 0 ? segments[index - 1] : null; + const isDeveloperSegment = previousSegment === "developers"; + const label = isDeveloperSegment ? formatUsername(segment, userMap) - : segmentLabels[segment] || decodeURIComponent(segment); - const isLast = index === segments.length - 1; + : (segmentLabels[segment] ?? decodeURIComponent(segment)); - return { href, label, isLast }; + return { + href, + label, + isLast: index === segments.length - 1, + }; }); return ( -