'use client' import { useState, useEffect, useRef, useCallback } from 'react' 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 { FAVICON_SERVICE_URL } from '@/lib/utils/icons' import { LayoutDashboardIcon, PathIcon, FunnelIcon, CursorClickIcon, SearchIcon, CloudUploadIcon, HeartbeatIcon, SettingsIcon, CollapseLeftIcon, CollapseRightIcon, ChevronUpDownIcon, PlusIcon, XIcon, } from '@ciphera-net/ui' const SIDEBAR_KEY = 'pulse_sidebar_collapsed' 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 }, ], }, ] const SETTINGS_ITEM: NavItem = { label: '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} ) } // ─── Site Picker ──────────────────────────────────────────── function SitePicker({ sites, siteId, collapsed }: { sites: Site[]; siteId: string; collapsed: boolean }) { const [open, setOpen] = useState(false) const [search, setSearch] = useState('') const [faviconFailed, setFaviconFailed] = useState(false) const ref = useRef(null) const pathname = usePathname() const router = useRouter() const currentSite = sites.find((s) => s.id === siteId) const initial = currentSite?.name?.charAt(0)?.toUpperCase() || '?' const faviconUrl = currentSite?.domain ? `${FAVICON_SERVICE_URL}?domain=${currentSite.domain}&sz=64` : null useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setSearch('') } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, []) const switchSite = (id: string) => { router.push(`/sites/${id}${pathname.replace(/^\/sites\/[^/]+/, '')}`) setOpen(false); setSearch('') } const filtered = sites.filter( (s) => s.name.toLowerCase().includes(search.toLowerCase()) || s.domain.toLowerCase().includes(search.toLowerCase()) ) return (
{open && (
setSearch(e.target.value)} className="w-full px-3 py-1.5 text-sm bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg outline-none focus:ring-2 focus:ring-brand-orange/40 text-neutral-900 dark:text-white placeholder:text-neutral-400" autoFocus />
{filtered.map((site) => ( ))} {filtered.length === 0 &&

No sites found

}
setOpen(false)} className="flex items-center gap-2 px-3 py-1.5 text-sm text-brand-orange hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg"> Add new site
)}
) } // ─── 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 return ( { onNavigate(href); onClick?.() }} title={collapsed ? item.label : undefined} className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium overflow-hidden ${ active ? 'bg-brand-orange/10 text-brand-orange' : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800' }`} > ) } // ─── Main Sidebar ─────────────────────────────────────────── export default function Sidebar({ siteId, mobileOpen, onMobileClose, onMobileOpen, }: { siteId: string; mobileOpen: boolean; onMobileClose: () => void; onMobileOpen: () => void }) { const { user } = useAuth() const canEdit = user?.role === 'owner' || user?.role === 'admin' const pathname = usePathname() const [sites, setSites] = useState([]) const [pendingHref, setPendingHref] = useState(null) const [collapsed, setCollapsed] = useState(() => { if (typeof window === 'undefined') return false return localStorage.getItem(SIDEBAR_KEY) === 'true' }) useEffect(() => { listSites().then(setSites).catch(() => {}) }, []) useEffect(() => { setPendingHref(null); onMobileClose() }, [pathname, onMobileClose]) useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === '[' && !e.metaKey && !e.ctrlKey && !e.altKey) { const tag = (e.target as HTMLElement)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return e.preventDefault(); toggle() } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [collapsed]) const toggle = useCallback(() => { setCollapsed((prev) => { const next = !prev; localStorage.setItem(SIDEBAR_KEY, String(next)); return next }) }, []) const handleNavigate = useCallback((href: string) => { setPendingHref(href) }, []) const sidebarContent = (isMobile: boolean) => { const c = isMobile ? false : collapsed return (
{/* Logo — fixed layout, text fades */} Pulse Pulse {/* Site Picker */} {/* Nav Groups */} {/* Bottom */}
{canEdit && ( )} {!isMobile && ( )}
) } return ( <> {/* Desktop — width transitions, internal layout never changes */} {/* Mobile overlay */} {mobileOpen && ( <>
)} ) }