import { useMemo } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import { ArrowUpRightIcon, ChevronRightIcon, PanelLeftCloseIcon, PanelLeftIcon, } from 'lucide-react'; import { navCount, useSeoBadges } from '../../context/seo-badges.context'; import { classNames } from '../../lib/utils'; import { SEO_NAV_SECTIONS } from '../../lib/seo/nav-sections'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { activeTreeKey, containsActivePath, queryLinkClaims, searchLinkActive, type NavItem, } from '../../lib/navigation/nav-model'; import { useNavAccordion, type NavAccordion, } from '../../lib/navigation/use-nav-accordion'; import logo from '../../assets/logo.png'; import { canSeeSeoAnalytics } from '../../lib/seo/analytics-access'; import { creditMeterLabel } from './credit-meter'; import { buildVisibleNav, type VisibleNavOptions } from './nav-config'; import { NavCountPill } from './nav-count'; import { flattenNav } from './nav-search'; import { NavBadge, NavTag } from './nav-pills'; import NavUserChip from './nav-user-chip'; import SidebarSearch from './sidebar-search'; export interface SidebarCredits { used: number; granted: number; } interface SidebarProps extends VisibleNavOptions { className?: string; email?: string; credits?: SidebarCredits | null; onHide?: () => void; } function NavTooltip({ description, children, }: { description?: string; children: React.ReactNode; }): React.ReactElement { if (!description) return <>{children}; return ( {children} {description} ); } /** * One 260px column: brand, search, the sections with one module open at a * time (the one holding the current route opens itself), then billing with * the month's credits and the account chip. */ export function Sidebar({ className, email, credits, onHide, canSeeBulkUpload, }: SidebarProps) { const badges = useSeoBadges().badges; const seoTypes = badges?.types; const seoSetupDone = badges?.setup_done ?? true; const seoModules = badges?.modules; const { sections, utilityItems } = useMemo( () => buildVisibleNav({ canSeeBulkUpload, canSeeSeoAnalytics: canSeeSeoAnalytics(), seoSetupDone, seoTypes, seoModules, }), [canSeeBulkUpload, seoSetupDone, seoTypes, seoModules] ); const searchItems = useMemo( () => flattenNav(sections, utilityItems), [sections, utilityItems] ); const location = useLocation(); const modules = useMemo( () => sections.flatMap(section => section.items), [sections] ); const accordion = useNavAccordion( activeTreeKey(location.pathname, modules, location.search) ); return ( ); } const navRowClasses = ({ isActive }: { isActive: boolean }): string => classNames( 'flex items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[14px] text-nav-foreground transition-colors hover:bg-nav-hover', isActive && 'bg-nav-hover font-medium' ); /** A module row carries the count of one of its pages while it is collapsed. */ const MODULE_COUNT_ROUTE: Record = { seo: '/seo/checkup' }; /** * Whether a query-addressed row of the third level is the one on screen: * same page, and every query key it names matches the location, the * page's default section standing in when the URL names none. */ const thirdLevelActive = ( pathname: string, search: string, to: string ): boolean => { const [path, query = ''] = to.split('?'); if (pathname !== path) return false; const have = new URLSearchParams(search); for (const [key, value] of new URLSearchParams(query)) { const fallback = key === 'section' ? (SEO_NAV_SECTIONS[path]?.defaultKey ?? null) : null; if ((have.get(key) ?? fallback) !== value) return false; } return true; }; function NavEntry({ item, accordion, pathname, search, }: { item: NavItem; accordion: NavAccordion; pathname: string; search: string; }) { const Icon = item.icon; const { counts } = useSeoBadges(); // A page with rows under it opens its rows when you are on the page // itself, not only on one of its rows, so the page counts as a row of its // own tree here. const trees = useMemo( () => (item.children ?? []).map(child => child.children && child.menuKey ? { ...child, children: [ ...child.children, { label: child.label, icon: child.icon, to: child.to }, ], } : child ), [item.children] ); const nested = useNavAccordion(activeTreeKey(pathname, trees, search)); if (!item.children) { return ( } > {item.label} {item.tag && } {item.badge && } ); } const menuKey = item.menuKey ?? item.label; const open = accordion.isOpen(menuKey); // A module whose page you are on reads as selected even while collapsed; // the chevron says whether its list is showing, not whether it is current. const holdsRoute = containsActivePath(pathname, item.children); const claimedByQuery = queryLinkClaims(pathname, search, item.children); const collapsedCount = open ? undefined : navCount(counts, MODULE_COUNT_ROUTE[menuKey]); return (
accordion.toggle(menuKey)} aria-expanded={open} className={classNames( 'flex w-full items-center gap-2.5 rounded-[10px] px-2.5 py-2 text-[14px] text-nav-foreground transition-colors hover:bg-nav-hover', holdsRoute && 'bg-nav-hover font-medium' )} /> } > {item.label} {item.tag && } {item.badge && } {collapsedCount && } {open && (
{item.children.map(child => ( ))}
)}
); } function SubLink({ item, pathname, search, siblingClaims, nested, }: { item: NavItem; pathname: string; search: string; siblingClaims: boolean; nested: NavAccordion; }) { const { counts } = useSeoBadges(); const count = navCount(counts, item.to); // A link carrying a query (?tab=) decides its own active state, or every // sibling on the same pathname would light at once. const byQuery = searchLinkActive(pathname, search, item); if (item.children && item.menuKey) { return ( ); } if (item.external) { return ( } > {item.label} ); } return ( classNames( 'flex items-center gap-2 rounded-[10px] px-2.5 py-1.5 text-[13px] transition-colors hover:bg-nav-hover', (byQuery ?? (isActive && !siblingClaims)) ? 'font-medium text-nav-foreground' : 'text-nav-idle hover:text-nav-foreground' ) } /> } > {item.label} {item.badge && } {count && } ); } /** * A page with rows of its own under it (the checkup's groups, a settings * page's sections): the page link with a chevron, then the rows, open on * the page you are on and one at a time otherwise. */ function SubTree({ item, pathname, search, nested, count, }: { item: NavItem; pathname: string; search: string; nested: NavAccordion; count: ReturnType; }) { const { counts } = useSeoBadges(); const menuKey = item.menuKey ?? item.label; const open = nested.isOpen(menuKey); const page = (item.to ?? '').split('?')[0]; const onPage = pathname === page; return (
} > {item.label} {count && }
{open && (
{(item.children ?? []).map(child => { if (!child.to) { return (

{child.label}

); } const active = thirdLevelActive(pathname, search, child.to); const childCount = navCount(counts, child.to); return ( } > {child.label} {childCount && } ); })}
)}
); } function SidebarFooter({ email, credits, utilityItems, pathname, }: { email?: string; credits?: SidebarCredits | null; utilityItems: NavItem[]; pathname: string; }) { const pct = credits && credits.granted > 0 ? Math.min(100, Math.round((credits.used / credits.granted) * 100)) : 0; const billing = utilityItems[0]; const Icon = billing?.icon; const active = !!billing?.to && containsActivePath(pathname, [billing]); return (
{billing?.to && ( {Icon && } {billing.label} {credits && credits.granted > 0 && ( <>

{creditMeterLabel(credits.used, credits.granted)}

)} )}
); } export function SidebarShowButton({ onShow }: { onShow: () => void }) { return ( ); }