'use client' import { useState, useEffect, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { listSites, type Site } from '@/lib/api/sites' import { useAuth } from '@/lib/auth/context' import { useUnifiedSettings } from '@/lib/unified-settings-context' import { useSidebar } from '@/lib/sidebar-context' // `,` shortcut handled globally by UnifiedSettingsModal import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization' import { setSessionAction } from '@/app/actions/auth' import { logger } from '@/lib/utils/logger' import { FAVICON_SERVICE_URL } from '@/lib/utils/favicon' import { Gauge as GaugeIcon, Plugs as PlugsIcon, Tag as TagIcon } from '@phosphor-icons/react' import { LayoutDashboardIcon, PathIcon, FunnelIcon, CursorClickIcon, SearchIcon, CloudUploadIcon, HeartbeatIcon, SettingsIcon, PlusIcon, XIcon, BookOpenIcon, UserMenu, } from '@ciphera-net/ui' import NotificationCenter from '@/components/notifications/NotificationCenter' const EXPANDED = 256 const COLLAPSED = 64 type IconWeight = 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone' interface NavItem { label: string href: (siteId: string) => string icon: React.ComponentType<{ className?: string; weight?: IconWeight }> matchPrefix?: boolean } interface NavGroup { label: string; items: NavItem[] } const NAV_GROUPS: NavGroup[] = [ { label: 'Analytics', items: [ { label: 'Dashboard', href: (id) => `/sites/${id}`, icon: LayoutDashboardIcon }, { label: 'Journeys', href: (id) => `/sites/${id}/journeys`, icon: PathIcon, matchPrefix: true }, { label: 'Funnels', href: (id) => `/sites/${id}/funnels`, icon: FunnelIcon, matchPrefix: true }, { label: 'Behavior', href: (id) => `/sites/${id}/behavior`, icon: CursorClickIcon, matchPrefix: true }, { label: 'Search', href: (id) => `/sites/${id}/search`, icon: SearchIcon, matchPrefix: true }, ], }, { label: 'Infrastructure', items: [ { label: 'CDN', href: (id) => `/sites/${id}/cdn`, icon: CloudUploadIcon, matchPrefix: true }, { label: 'Uptime', href: (id) => `/sites/${id}/uptime`, icon: HeartbeatIcon, matchPrefix: true }, { label: 'PageSpeed', href: (id) => `/sites/${id}/pagespeed`, icon: GaugeIcon, matchPrefix: true }, ], }, ] const SETTINGS_ITEM: NavItem = { label: 'Site Settings', href: (id) => `/sites/${id}/settings`, icon: SettingsIcon, matchPrefix: true, } // Label that fades with the sidebar — always in the DOM, never removed function Label({ children, collapsed }: { children: React.ReactNode; collapsed: boolean }) { return ( {children} ) } // ─── Sidebar Tooltip (portal-based, escapes overflow-hidden) ── function SidebarTooltip({ children, label }: { children: React.ReactNode; label: string }) { const [show, setShow] = useState(false) const [pos, setPos] = useState({ x: 0, y: 0 }) const ref = useRef(null) const timerRef = useRef>(undefined) const handleEnter = () => { timerRef.current = setTimeout(() => { if (ref.current) { const rect = ref.current.getBoundingClientRect() setPos({ x: rect.right + 8, y: rect.top + rect.height / 2 }) setShow(true) } }, 400) } const handleLeave = () => { clearTimeout(timerRef.current) setShow(false) } return (
{children} {show && typeof document !== 'undefined' && createPortal( {label} , document.body )}
) } // ─── Nav Item ─────────────────────────────────────────────── function NavLink({ item, siteId, collapsed, onClick, pendingHref, onNavigate, }: { item: NavItem; siteId: string; collapsed: boolean; onClick?: () => void pendingHref: string | null; onNavigate: (href: string) => void }) { const pathname = usePathname() const href = item.href(siteId) const matchesPathname = item.matchPrefix ? pathname.startsWith(href) : pathname === href const matchesPending = pendingHref !== null && (item.matchPrefix ? pendingHref.startsWith(href) : pendingHref === href) const active = matchesPathname || matchesPending const link = ( { onNavigate(href); onClick?.() }} className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden transition-all duration-150 ${ active ? 'bg-brand-orange/10 text-brand-orange' : 'text-neutral-400 hover:text-white hover:bg-white/[0.06] hover:translate-x-0.5' }`} > ) if (collapsed) return {link} return link } // ─── Settings Button (opens unified modal instead of navigating) ───── function SettingsButton({ item, collapsed, onClick, settingsContext = 'site', }: { item: NavItem; collapsed: boolean; onClick?: () => void; settingsContext?: 'site' | 'workspace' }) { const { openUnifiedSettings } = useUnifiedSettings() const btn = ( ) if (collapsed) return {btn} return btn } // ─── Home Nav Link (static href, no siteId) ─────────────── function HomeNavLink({ href, icon: Icon, label, collapsed, onClick, external, }: { href: string; icon: React.ComponentType<{ className?: string; weight?: IconWeight }> label: string; collapsed: boolean; onClick?: () => void; external?: boolean }) { const pathname = usePathname() const active = !external && pathname === href const link = ( ) if (collapsed) return {link} return link } // ─── Home Site Link (favicon + name) ─────────────────────── function HomeSiteLink({ site, collapsed, onClick, }: { site: Site; collapsed: boolean; onClick?: () => void }) { const pathname = usePathname() const href = `/sites/${site.id}` const active = pathname.startsWith(href) const link = ( ) if (collapsed) return {link} return link } // ─── Sidebar Content ──────────────────────────────────────── interface SidebarContentProps { isMobile: boolean collapsed: boolean siteId: string | null sites: Site[] canEdit: boolean pendingHref: string | null onNavigate: (href: string) => void onMobileClose: () => void onToggle: () => void auth: ReturnType orgs: OrganizationMember[] onSwitchOrganization: (orgId: string | null) => Promise openSettings: () => void openOrgSettings: () => void } function SidebarContent({ isMobile, collapsed, siteId, sites, canEdit, pendingHref, onNavigate, onMobileClose, onToggle, auth, orgs, onSwitchOrganization, openSettings, openOrgSettings, }: SidebarContentProps) { const router = useRouter() const c = isMobile ? false : collapsed const { user } = auth return (
{/* Logo — fixed layout, text fades */} Pulse Pulse {/* Nav Groups */} {siteId ? ( ) : ( )} {/* Bottom — utility items */}
{/* Notifications, Profile — same layout as nav items */}
{c ? ( ) : ( )} {c ? ( router.push('/onboarding')} allowPersonalOrganization={false} onOpenSettings={openSettings} onOpenOrgSettings={openOrgSettings} compact anchor="right" > ) : ( router.push('/onboarding')} allowPersonalOrganization={false} onOpenSettings={openSettings} onOpenOrgSettings={openOrgSettings} compact anchor="right" > )}
) } // ─── Main Sidebar ─────────────────────────────────────────── export default function Sidebar({ siteId, mobileOpen, onMobileClose, onMobileOpen, }: { siteId: string | null; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void }) { const auth = useAuth() const { user } = auth const canEdit = user?.role === 'owner' || user?.role === 'admin' const pathname = usePathname() const router = useRouter() const { openUnifiedSettings } = useUnifiedSettings() const [sites, setSites] = useState([]) const [orgs, setOrgs] = useState([]) const [pendingHref, setPendingHref] = useState(null) const [mobileClosing, setMobileClosing] = useState(false) const { collapsed, toggle } = useSidebar() useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { if (user) { getUserOrganizations() .then((organizations) => setOrgs(Array.isArray(organizations) ? organizations : [])) .catch(err => logger.error('Failed to fetch orgs', err)) } }, [user]) const handleSwitchOrganization = async (orgId: string | null) => { if (!orgId) return try { const { access_token } = await switchContext(orgId) await setSessionAction(access_token) await auth.refresh() router.push('/') } catch (err) { logger.error('Failed to switch organization', err) } } useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) const handleMobileClose = useCallback(() => { setMobileClosing(true) setTimeout(() => { setMobileClosing(false) onMobileClose() }, 200) }, [onMobileClose]) const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) return ( <> {/* Desktop — ssr:false means this only renders on client, no hydration flash */} {/* Mobile overlay */} {(mobileOpen || mobileClosing) && ( <>
)} ) }