← back to Norma
components/Sidebar.tsx
1330 lines
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ImpactStrip from '@/components/shared/ImpactStrip';
import { useOrg } from './OrgProvider';
import ManageTabsModal from './ManageTabsModal';
import {
FileText,
Newspaper,
Twitter,
BookOpen,
Settings,
Megaphone,
Award,
Plug,
ChevronLeft,
ChevronRight,
ChevronDown,
GraduationCap,
Trophy,
Handshake,
Users,
Flame,
Send,
BookOpenCheck,
MessageCircle,
MapPin,
AlertTriangle,
Target,
BarChart3,
TrendingUp,
FileInput,
Lightbulb,
Network,
GitFork,
Building2,
Landmark,
Vote,
Waypoints,
Bot,
ScrollText,
Search,
UserPlus,
Radio,
Rocket,
GitBranch,
Map,
Grid3x3,
Crosshair,
Globe,
Database,
DollarSign,
BadgeDollarSign,
Key,
Zap,
Brain,
Activity,
Rss,
LayoutDashboard,
Boxes,
Sun,
MoreHorizontal,
Globe2,
Mail,
Inbox,
Sparkles,
Eye,
EyeOff,
SlidersHorizontal,
Clock,
FolderClock,
} from 'lucide-react';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export type TabId =
| 'dashboard'
| 'drafts'
| 'news'
| 'petitions'
| 'petition-ideas'
| 'grants'
| 'funding'
| 'collaborations'
| 'partners'
| 'oldleads'
| 'heypartner'
| 'playbook'
| 'community'
| 'xsessions'
| 'library'
| 'integrations'
| 'awards'
| 'settings'
| 'idea-mindmap'
| 'partner-tree'
| 'organizations'
| 'foundations'
| 'geo-report'
| 'geo-hotspots'
| 'geo-advocacy'
| 'geo-scatter'
| 'geo-trends'
| 'geo-moveon'
| 'pulse-feed'
| 'congress'
| 'opportunity-map'
| 'agent-hub'
| 'agent-petition'
| 'agent-grants'
| 'agent-advocacy'
| 'agent-onboard'
| 'moveon-launch'
| 'pipeline'
| 'geo-choropleth'
| 'geo-topic-grid'
| 'topic-radar'
| 'statements'
| 'pulse-globe'
| 'pulse-mindmap'
| 'pulse-datasources'
| 'pulse-onboard'
| 'petition-creator'
| 'trending-petitions'
| 'journalists'
| 'nexus-explorer'
| 'nexus-power-map'
| 'nexus-opportunities'
| 'nexus-deep-insights'
| 'pulse-heatmap'
| 'live-news'
| 'viz-hub'
| 'viz-tree'
| 'viz-matrix'
| 'viz-3d'
| 'viz-sunburst'
| 'apis'
| 'donations'
| 'contacts'
| 'org-connections'
| 'power-intelligence'
| 'in-the-news'
| 'email-sends'
| 'email-statements'
| 'email-donations'
| 'email-breaking'
| 'email-education'
| 'email-workshops'
| 'email-congress'
| 'email-agencies'
| 'email-journalists'
| 'clients'
| 'gmail-crm'
| 'email-analyzer'
| 'email-analyzer-saved'
| 'price-tracker'
| 'social-overview'
| 'social-compose'
| 'social-schedule'
| 'social-analytics'
| 'social-accounts'
| 'social-competitors'
| 'social-scoreboard';
type SectionId =
| 'workspace'
| 'email-sends'
| 'social'
| 'intelligence'
| 'geo'
| 'pulse'
| 'congress'
| 'nexus'
| 'agents'
| 'universal';
interface NavItem {
id: TabId;
label: string;
Icon: React.ElementType;
section: SectionId;
}
interface SidebarProps {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
isOpen?: boolean;
onToggle?: () => void;
role?: 'admin' | 'staff' | 'intern' | 'pulse';
}
/* ─── Tabs excluded for staff role ────────────────────────────────────────── */
/* Staff only sees Email & Outreach + Settings by default.
Other modules can be activated from Settings → Module Manager. */
const STAFF_DEFAULT_TABS: TabId[] = [
// Workspace
'dashboard', 'drafts', 'news', 'petitions', 'grants', 'pipeline', 'statements', 'donations',
// Email & Outreach
'email-sends', 'email-analyzer', 'email-analyzer-saved', 'gmail-crm', 'clients',
// Intelligence
'power-intelligence', 'in-the-news', 'journalists', 'contacts', 'community',
'topic-radar', 'petition-creator', 'trending-petitions', 'live-news',
// Congress
'congress',
];
const STAFF_EXCLUDED_TABS: TabId[] = [
'apis',
'integrations',
'organizations',
'foundations',
'org-connections',
];
/* ─── Section config ─────────────────────────────────────────────────────── */
const ORG_LOCAL_SECTIONS: SectionId[] = [
'workspace', 'email-sends', 'social', 'intelligence', 'geo', 'pulse', 'congress', 'nexus', 'agents',
];
interface SectionMeta {
label: (orgShortName?: string) => string;
defaultOpen: boolean;
}
const SECTION_META: Record<SectionId, SectionMeta> = {
workspace: { label: (s) => s ? `${s} Workspace` : 'Workspace', defaultOpen: true },
'email-sends': { label: () => 'Email & Outreach', defaultOpen: false },
social: { label: () => 'Social Media', defaultOpen: false },
intelligence: { label: () => 'Intelligence', defaultOpen: false },
geo: { label: () => 'Geo Intelligence', defaultOpen: false },
pulse: { label: () => 'Pulse', defaultOpen: false },
congress: { label: () => 'Congress', defaultOpen: false },
nexus: { label: () => 'Nexus', defaultOpen: false },
agents: { label: () => 'AI Agents', defaultOpen: false },
universal: { label: () => 'Universal', defaultOpen: false },
};
/* ─── Keyboard shortcut hints ────────────────────────────────────────────── */
const KBD_MAP: Partial<Record<TabId, string>> = {
dashboard: 'H',
drafts: 'D',
petitions: 'P',
'topic-radar': 'R',
pipeline: 'L',
contacts: 'N',
};
/* ─── Navigation config ──────────────────────────────────────────────────── */
const NAV_ITEMS: NavItem[] = [
// workspace — org-local, pinned at top
{ id: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, section: 'workspace' },
{ id: 'drafts', label: 'Drafts', Icon: FileText, section: 'workspace' },
{ id: 'news', label: 'News', Icon: Newspaper, section: 'workspace' },
{ id: 'petitions', label: 'Petitions', Icon: Megaphone, section: 'workspace' },
{ id: 'grants', label: 'Grants', Icon: Award, section: 'workspace' },
{ id: 'pipeline', label: 'Pipeline', Icon: GitBranch, section: 'workspace' },
{ id: 'statements', label: 'Statements', Icon: Megaphone, section: 'workspace' },
{ id: 'donations', label: 'Donations', Icon: DollarSign, section: 'workspace' },
// email-sends — org-local (type filter is a dropdown inside Email Center)
{ id: 'email-sends', label: 'Email Center', Icon: Mail, section: 'email-sends' },
{ id: 'email-analyzer', label: 'Email Analyzer', Icon: Sparkles, section: 'email-sends' },
{ id: 'email-analyzer-saved', label: 'Saved Analyses', Icon: FolderClock, section: 'email-sends' },
{ id: 'clients', label: 'Clients / Borrowers', Icon: Users, section: 'email-sends' },
{ id: 'gmail-crm', label: 'Gmail CRM', Icon: Inbox, section: 'email-sends' },
// social — org-local
{ id: 'social-overview', label: 'Social Hub', Icon: Globe2, section: 'social' },
{ id: 'social-compose', label: 'Compose', Icon: Send, section: 'social' },
{ id: 'social-schedule', label: 'Schedule', Icon: Clock, section: 'social' },
{ id: 'social-analytics', label: 'Analytics', Icon: BarChart3, section: 'social' },
{ id: 'social-competitors', label: 'Competitors', Icon: Eye, section: 'social' },
{ id: 'social-scoreboard', label: 'Scoreboard', Icon: Trophy, section: 'social' },
{ id: 'social-accounts', label: 'Accounts', Icon: Key, section: 'social' },
// intelligence — org-local
{ id: 'power-intelligence', label: 'Power Intel', Icon: Zap, section: 'intelligence' },
{ id: 'in-the-news', label: 'In the News', Icon: Rss, section: 'intelligence' },
{ id: 'journalists', label: 'Journalists', Icon: Users, section: 'intelligence' },
{ id: 'contacts', label: 'Network', Icon: UserPlus, section: 'intelligence' },
{ id: 'community', label: 'Community', Icon: MessageCircle, section: 'intelligence' },
{ id: 'topic-radar', label: 'Topic Radar', Icon: Crosshair, section: 'intelligence' },
{ id: 'petition-ideas', label: 'Petition Ideas', Icon: Lightbulb, section: 'intelligence' },
{ id: 'petition-creator', label: 'Create Petition', Icon: Rocket, section: 'intelligence' },
{ id: 'trending-petitions', label: 'Trending Petitions', Icon: TrendingUp, section: 'intelligence' },
{ id: 'live-news', label: 'Live News', Icon: Rss, section: 'intelligence' },
{ id: 'price-tracker', label: 'Price Tracker', Icon: BadgeDollarSign, section: 'intelligence' },
// geo — org-local
{ id: 'geo-report', label: 'Zip Report', Icon: MapPin, section: 'geo' },
{ id: 'geo-hotspots', label: 'Hotspot Map', Icon: AlertTriangle, section: 'geo' },
{ id: 'geo-advocacy', label: 'Advocacy Targets', Icon: Target, section: 'geo' },
{ id: 'geo-scatter', label: 'Cross-Ref', Icon: BarChart3, section: 'geo' },
{ id: 'geo-trends', label: 'Trend Pulse', Icon: TrendingUp, section: 'geo' },
{ id: 'geo-moveon', label: 'MoveOn Import', Icon: FileInput, section: 'geo' },
{ id: 'moveon-launch', label: 'MoveOn Launch', Icon: Rocket, section: 'geo' },
{ id: 'geo-choropleth', label: 'State Heat Map', Icon: Map, section: 'geo' },
{ id: 'geo-topic-grid', label: 'Topic Grid', Icon: Grid3x3, section: 'geo' },
// pulse — org-local
{ id: 'pulse-feed', label: 'Pulse Feed', Icon: Radio, section: 'pulse' },
{ id: 'pulse-globe', label: 'Globe', Icon: Globe, section: 'pulse' },
{ id: 'pulse-mindmap', label: 'Mind Map', Icon: Network, section: 'pulse' },
{ id: 'pulse-datasources', label: 'Data Sources', Icon: Database, section: 'pulse' },
{ id: 'pulse-onboard', label: 'OnBoard', Icon: UserPlus, section: 'pulse' },
{ id: 'pulse-heatmap', label: 'Pulse Heat Map', Icon: Activity, section: 'pulse' },
// congress — org-local
{ id: 'congress', label: 'Congress', Icon: Vote, section: 'congress' },
{ id: 'opportunity-map', label: 'Opportunity Map', Icon: Waypoints, section: 'congress' },
// nexus — org-local
{ id: 'nexus-explorer', label: 'Nexus Graph', Icon: Network, section: 'nexus' },
{ id: 'nexus-power-map', label: 'Power Map', Icon: Zap, section: 'nexus' },
{ id: 'nexus-opportunities', label: 'Opportunities', Icon: Target, section: 'nexus' },
{ id: 'nexus-deep-insights', label: 'Deep Insights', Icon: Brain, section: 'nexus' },
{ id: 'viz-hub', label: 'Viz Hub', Icon: LayoutDashboard, section: 'nexus' },
{ id: 'viz-tree', label: 'Connection Tree', Icon: GitFork, section: 'nexus' },
{ id: 'viz-matrix', label: 'Correlation Map', Icon: Grid3x3, section: 'nexus' },
{ id: 'viz-3d', label: '3D Network', Icon: Boxes, section: 'nexus' },
{ id: 'viz-sunburst', label: 'Sunburst', Icon: Sun, section: 'nexus' },
// agents — org-local
{ id: 'agent-hub', label: 'Agent Apps', Icon: Bot, section: 'agents' },
{ id: 'agent-petition', label: 'Petition Builder', Icon: ScrollText, section: 'agents' },
{ id: 'agent-grants', label: 'Grant Finder', Icon: Search, section: 'agents' },
{ id: 'agent-advocacy', label: 'Advocacy Kit', Icon: Target, section: 'agents' },
{ id: 'agent-onboard', label: 'Org Onboard', Icon: UserPlus, section: 'agents' },
// universal — same for all orgs
{ id: 'organizations', label: 'Organizations', Icon: Building2, section: 'universal' },
{ id: 'foundations', label: 'Foundations', Icon: Landmark, section: 'universal' },
{ id: 'library', label: 'Library', Icon: BookOpen, section: 'universal' },
{ id: 'apis', label: 'API Keys', Icon: Key, section: 'universal' },
{ id: 'integrations', label: 'Integrations', Icon: Plug, section: 'universal' },
{ id: 'awards', label: 'Awards', Icon: Trophy, section: 'universal' },
{ id: 'org-connections', label: 'Org Connections', Icon: Network, section: 'universal' },
{ id: 'settings', label: 'Settings', Icon: Settings, section: 'universal' },
{ id: 'xsessions', label: 'X Sessions', Icon: Twitter, section: 'universal' },
{ id: 'collaborations', label: 'Collaborations', Icon: Handshake, section: 'universal' },
{ id: 'partners', label: 'Partners', Icon: Users, section: 'universal' },
{ id: 'oldleads', label: 'Old Leads', Icon: Flame, section: 'universal' },
{ id: 'heypartner', label: 'Hey Partner', Icon: Send, section: 'universal' },
{ id: 'playbook', label: 'Playbook', Icon: BookOpenCheck, section: 'universal' },
{ id: 'funding', label: 'Funding', Icon: GraduationCap, section: 'universal' },
{ id: 'idea-mindmap', label: 'Idea Mind Map', Icon: Brain, section: 'universal' },
{ id: 'partner-tree', label: 'Partner Tree', Icon: GitFork, section: 'universal' },
];
/* ─── Pinned workspace items (always visible before "More") ──────────────── */
const PINNED_IDS: TabId[] = [
'dashboard', 'drafts', 'news', 'petitions', 'grants', 'pipeline', 'contacts', 'community',
];
/* ─── localStorage helpers ───────────────────────────────────────────────── */
function readLS(key: string, fallback: boolean): boolean {
if (typeof window === 'undefined') return fallback;
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v === 'true';
} catch {
return fallback;
}
}
function writeLS(key: string, value: boolean) {
try { localStorage.setItem(key, String(value)); } catch { /* noop */ }
}
/* ─── Hidden tabs helpers ───────────────────────────────────────────────── */
function getHiddenTabsKey(role?: 'admin' | 'staff' | 'intern' | 'pulse'): string {
if (role === 'staff' || role === 'intern' || role === 'pulse') {
const orgId = typeof window !== 'undefined'
? localStorage.getItem('norma-user-orgid') || 'default'
: 'default';
return `nikki-hidden-tabs-${orgId}`;
}
return 'norma-hidden-tabs';
}
function readHiddenTabs(key: string): Set<TabId> {
if (typeof window === 'undefined') return new Set();
try {
const raw = localStorage.getItem(key);
if (!raw) return new Set();
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return new Set(arr as TabId[]);
} catch { /* noop */ }
return new Set();
}
function writeHiddenTabs(key: string, tabs: Set<TabId>) {
try {
localStorage.setItem(key, JSON.stringify(Array.from(tabs)));
} catch { /* noop */ }
}
/* ─── OrgBadge ───────────────────────────────────────────────────────────── */
function OrgBadge({ shortName, brandColor }: { shortName: string; brandColor: string }) {
return (
<span
aria-label={`Org: ${shortName}`}
style={{
display: 'inline-flex',
alignItems: 'center',
padding: '1px 6px',
borderRadius: 4,
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
background: `${brandColor}22`,
color: brandColor,
border: `1px solid ${brandColor}44`,
lineHeight: 1.5,
flexShrink: 0,
}}
>
{shortName.slice(0, 4)}
</span>
);
}
/* ─── UniversalDivider ───────────────────────────────────────────────────── */
function UniversalDivider({ collapsed }: { collapsed: boolean }) {
if (collapsed) {
return (
<div
aria-hidden="true"
style={{
height: 1,
margin: '10px 12px',
background: 'linear-gradient(90deg, transparent, var(--color-border), transparent)',
borderRadius: 1,
}}
/>
);
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
margin: '14px 8px 2px',
}}
aria-label="Universal section — same across all organizations"
>
<div
style={{
flex: 1,
height: 1,
background: 'var(--color-border)',
borderRadius: 1,
}}
aria-hidden="true"
/>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 8px',
borderRadius: 20,
border: '1px solid var(--color-border)',
backgroundColor: 'var(--color-surface-el)',
}}
>
<Globe2 size={10} aria-hidden="true" style={{ color: 'var(--color-text-muted)' }} />
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
}}
>
Universal
</span>
</div>
<div
style={{
flex: 1,
height: 1,
background: 'var(--color-border)',
borderRadius: 1,
}}
aria-hidden="true"
/>
</div>
);
}
/* ─── SidebarSection ─────────────────────────────────────────────────────── */
interface SidebarSectionProps {
sectionId: SectionId;
title: string;
items: NavItem[];
collapsed: boolean;
activeTab: TabId;
onTabChange: (tab: TabId) => void;
defaultOpen?: boolean;
pinnedCount?: number;
orgBadge?: { shortName: string; brandColor: string } | null;
onToggleHide?: (tabId: TabId) => void;
hiddenTabs?: Set<TabId>;
}
function SidebarSection({
sectionId,
title,
items,
collapsed,
activeTab,
onTabChange,
defaultOpen = false,
pinnedCount,
orgBadge,
onToggleHide,
hiddenTabs,
}: SidebarSectionProps) {
const lsKey = `sidebar-section-${sectionId}`;
const activeIsHere = items.some((i) => i.id === activeTab);
const [isOpen, setIsOpen] = useState<boolean>(() =>
activeIsHere ? true : readLS(lsKey, defaultOpen)
);
const [moreOpen, setMoreOpen] = useState<boolean>(() =>
readLS(`${lsKey}-more`, false)
);
const prevActiveIsHere = useRef(activeIsHere);
useEffect(() => {
// Only auto-expand when the active tab enters this section from outside,
// not when the user manually collapses it while already in the section.
if (activeIsHere && !prevActiveIsHere.current && !isOpen) {
setIsOpen(true);
writeLS(lsKey, true);
}
prevActiveIsHere.current = activeIsHere;
}, [activeIsHere, isOpen, lsKey]);
const pinnedItems = pinnedCount !== undefined ? items.slice(0, pinnedCount) : items;
const overflowItems = pinnedCount !== undefined ? items.slice(pinnedCount) : [];
const activeIsInOverflow = overflowItems.some((i) => i.id === activeTab);
useEffect(() => {
if (activeIsInOverflow && !moreOpen) {
setMoreOpen(true);
writeLS(`${lsKey}-more`, true);
}
}, [activeIsInOverflow, moreOpen, lsKey]);
const toggleSection = useCallback(() => {
setIsOpen((prev) => { writeLS(lsKey, !prev); return !prev; });
}, [lsKey]);
const toggleMore = useCallback(() => {
setMoreOpen((prev) => { writeLS(`${lsKey}-more`, !prev); return !prev; });
}, [lsKey]);
// Icon-only (collapsed) mode: dot divider + all items
if (collapsed) {
return (
<>
<div
aria-hidden="true"
style={{
height: 1,
margin: '6px 12px',
background: 'var(--color-border)',
borderRadius: 1,
opacity: 0.5,
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{items.map(({ id, label, Icon }) => (
<SidebarButton
key={id}
id={id}
label={label}
Icon={Icon}
isActive={activeTab === id}
collapsed={collapsed}
onClick={() => onTabChange(id)}
kbd={KBD_MAP[id]}
onToggleHide={onToggleHide}
isHiddenByUser={hiddenTabs?.has(id)}
/>
))}
</div>
</>
);
}
return (
<div>
{/* Section header */}
<button
type="button"
onClick={toggleSection}
aria-expanded={isOpen}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '8px 8px',
minHeight: 36,
marginTop: 8,
background: 'none',
border: 'none',
cursor: 'pointer',
borderRadius: 6,
color: 'var(--color-text-muted)',
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-text-secondary)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)'; }}
>
{/* Title + optional org badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</span>
{orgBadge && (
<OrgBadge shortName={orgBadge.shortName} brandColor={orgBadge.brandColor} />
)}
</div>
{/* Count + chevron */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
{!isOpen && (
<span style={{ fontSize: 9, color: 'var(--color-text-muted)', opacity: 0.6 }}>
{items.length}
</span>
)}
<ChevronDown
size={14}
aria-hidden="true"
style={{
transform: isOpen ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
}}
/>
</div>
</button>
{/* Items */}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{pinnedItems.map(({ id, label, Icon }) => (
<SidebarButton
key={id}
id={id}
label={label}
Icon={Icon}
isActive={activeTab === id}
collapsed={collapsed}
onClick={() => onTabChange(id)}
kbd={KBD_MAP[id]}
onToggleHide={onToggleHide}
isHiddenByUser={hiddenTabs?.has(id)}
/>
))}
{/* Overflow "More" area */}
{overflowItems.length > 0 && (
<>
<button
type="button"
onClick={toggleMore}
aria-expanded={moreOpen}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '8px 12px',
minHeight: 38,
border: 'none',
background: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted)',
fontSize: 13,
fontWeight: 500,
borderRadius: 6,
width: '100%',
textAlign: 'left',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-text-secondary)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)'; }}
>
<MoreHorizontal size={13} aria-hidden="true" />
<span>{moreOpen ? 'Less' : `${overflowItems.length} more`}</span>
<ChevronDown
size={11}
aria-hidden="true"
style={{
transform: moreOpen ? 'rotate(0deg)' : 'rotate(-90deg)',
transition: 'transform 0.2s',
marginLeft: 'auto',
}}
/>
</button>
{moreOpen && overflowItems.map(({ id, label, Icon }) => (
<SidebarButton
key={id}
id={id}
label={label}
Icon={Icon}
isActive={activeTab === id}
collapsed={collapsed}
onClick={() => onTabChange(id)}
kbd={KBD_MAP[id]}
onToggleHide={onToggleHide}
isHiddenByUser={hiddenTabs?.has(id)}
/>
))}
</>
)}
</div>
)}
</div>
);
}
/* ─── Sidebar ────────────────────────────────────────────────────────────── */
export default function Sidebar({ activeTab, onTabChange, isOpen, onToggle, role }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const [orgDropdownOpen, setOrgDropdownOpen] = useState(false);
const [manageTabsOpen, setManageTabsOpen] = useState(false);
const { org, orgs, switchOrg } = useOrg();
const brandColor = org?.brand_color || '#10b981';
const brandShort = org?.short_name || 'N';
const isAdmin = !role || role === 'admin';
// ── Hidden tabs persistence ───────────────────────────────────────────
const hiddenTabsKey = useMemo(() => getHiddenTabsKey(role), [role]);
const [hiddenTabs, setHiddenTabs] = useState<Set<TabId>>(() =>
readHiddenTabs(hiddenTabsKey),
);
// Re-read hidden tabs when the key changes (role or org switch)
useEffect(() => {
setHiddenTabs(readHiddenTabs(hiddenTabsKey));
}, [hiddenTabsKey]);
const toggleHideTab = useCallback((tabId: TabId) => {
setHiddenTabs((prev) => {
const next = new Set(prev);
if (next.has(tabId)) next.delete(tabId);
else next.add(tabId);
writeHiddenTabs(hiddenTabsKey, next);
return next;
});
}, [hiddenTabsKey]);
const resetHiddenTabs = useCallback(() => {
setHiddenTabs(new Set());
writeHiddenTabs(hiddenTabsKey, new Set());
}, [hiddenTabsKey]);
// Staff: only show default tabs + any user-activated modules from localStorage
const [staffActivatedModules, setStaffActivatedModules] = useState<Set<string>>(new Set());
useEffect(() => {
if (role !== 'staff') return;
function loadModules() {
try {
const saved = localStorage.getItem('nikki-activated-modules');
if (saved) setStaffActivatedModules(new Set(JSON.parse(saved)));
else setStaffActivatedModules(new Set());
} catch {}
}
loadModules();
// Listen for changes from ModuleManager
window.addEventListener('storage', loadModules);
return () => window.removeEventListener('storage', loadModules);
}, [role]);
const roleFilteredItems = role === 'staff'
? NAV_ITEMS.filter((i) =>
STAFF_DEFAULT_TABS.includes(i.id) ||
staffActivatedModules.has(i.id)
).filter((i) => !STAFF_EXCLUDED_TABS.includes(i.id))
: NAV_ITEMS;
// Filter out user-hidden tabs (but never hide the active tab)
const filteredNavItems = roleFilteredItems.filter(
(i) => !hiddenTabs.has(i.id) || i.id === activeTab,
);
// All tabs available for this role (used by ManageTabsModal)
const allAvailableTabs = useMemo(
() => roleFilteredItems.map((i) => ({ id: i.id, label: i.label, section: i.section })),
[roleFilteredItems],
);
// Group items by section
const itemsBySection = (section: SectionId) =>
filteredNavItems.filter((i) => i.section === section);
// Workspace uses PINNED_IDS to split pinned vs overflow;
// pinned items that appear in workspace come first up to PINNED_IDS.length
const workspaceItems = itemsBySection('workspace');
// Build pinned-first order: items in PINNED_IDS order, then the rest (using filtered list)
const workspacePinned = PINNED_IDS.filter((id) => workspaceItems.find((i) => i.id === id));
const workspaceRest = workspaceItems.filter((i) => !PINNED_IDS.includes(i.id));
const workspaceOrdered = [
...workspacePinned.map((id) => workspaceItems.find((i) => i.id === id)!),
...workspaceRest,
];
// Org badge props for org-local section headers
const orgBadge = { shortName: brandShort, brandColor };
return (
<>
{isOpen && onToggle && (
<div
onClick={onToggle}
className="sidebar-backdrop"
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.25)',
zIndex: 39,
}}
/>
)}
<aside
className={`sidebar-root${isOpen ? ' sidebar-open' : ''}`}
style={{
width: collapsed ? 64 : 240,
minWidth: collapsed ? 64 : 240,
transition: 'width 0.2s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
height: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
position: 'relative',
overflow: 'hidden',
flexShrink: 0,
}}
aria-label="Main navigation"
>
{/* ── Org Switcher ─────────────────────────────────────────────────── */}
<div
style={{
padding: collapsed ? '12px 0' : '10px 12px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
position: 'relative',
}}
>
<button
type="button"
onClick={() => isAdmin && !collapsed && setOrgDropdownOpen((p) => !p)}
title={collapsed ? (org?.org_name || 'Norma') : undefined}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: collapsed ? '4px 0' : '6px 8px',
justifyContent: collapsed ? 'center' : 'flex-start',
border: 'none',
background: orgDropdownOpen ? 'var(--color-surface-el)' : 'none',
borderRadius: 8,
cursor: isAdmin && orgs.length > 1 ? 'pointer' : 'default',
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => { if (isAdmin && orgs.length > 1) e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={(e) => { if (!orgDropdownOpen) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div
style={{
width: 32,
height: 32,
borderRadius: 10,
background: `linear-gradient(135deg, ${brandColor}, ${brandColor}cc)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
boxShadow: `0 2px 8px ${brandColor}4d`,
}}
>
<span style={{ color: '#fff', fontSize: brandShort.length > 3 ? 10 : 14, fontWeight: 900, lineHeight: 1 }}>
{brandShort.slice(0, 4)}
</span>
</div>
{!collapsed && (
<div style={{ overflow: 'hidden', whiteSpace: 'nowrap', flex: 1, textAlign: 'left' }}>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--color-text)', lineHeight: 1.2, fontFamily: 'var(--font-heading)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{org?.org_name || 'Norma'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.2, letterSpacing: '0.02em' }}>
Nonprofit Hub
</div>
</div>
)}
{!collapsed && isAdmin && orgs.length > 1 && (
<ChevronDown
size={13}
style={{
color: 'var(--color-text-muted)',
flexShrink: 0,
transform: orgDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
}}
/>
)}
</button>
{/* Org dropdown — admin only */}
{isAdmin && orgDropdownOpen && !collapsed && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setOrgDropdownOpen(false)}
/>
<div
style={{
position: 'absolute',
top: '100%',
left: 8,
right: 8,
marginTop: 4,
zIndex: 100,
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
padding: 6,
maxHeight: 280,
overflowY: 'auto',
}}
>
<div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600, color: 'var(--color-text-muted)', letterSpacing: '0.06em', textTransform: 'uppercase' }}>
Switch Organization
</div>
{orgs.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
switchOrg(o.id);
setOrgDropdownOpen(false);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
padding: '8px 10px',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
background: o.id === org?.id ? `${o.brand_color}22` : 'none',
borderLeft: o.id === org?.id ? `3px solid ${o.brand_color}` : '3px solid transparent',
transition: 'background-color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-surface-el)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = o.id === org?.id ? `${o.brand_color}22` : 'transparent'; }}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 8,
background: `linear-gradient(135deg, ${o.brand_color}, ${o.brand_color}cc)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span style={{ color: '#fff', fontSize: (o.short_name || '').length > 3 ? 8 : 11, fontWeight: 800 }}>
{(o.short_name || o.org_name.slice(0, 2)).slice(0, 4)}
</span>
</div>
<div style={{ textAlign: 'left', overflow: 'hidden' }}>
<div style={{
fontSize: 14,
fontWeight: o.id === org?.id ? 600 : 500,
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.2,
}}>
{o.org_name}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.2 }}>
{o.address_city}{o.address_city && o.address_state ? ', ' : ''}{o.address_state}
</div>
</div>
</button>
))}
</div>
</>
)}
</div>
{/* ── Main Nav ─────────────────────────────────────────────────────── */}
<nav
style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '4px 8px 0' }}
aria-label="Primary navigation"
>
{/* ── ORG-LOCAL sections ──────────────────────────────────────────── */}
{/* Workspace — pinned at top with overflow "More" */}
<SidebarSection
sectionId="workspace"
title={SECTION_META.workspace.label(collapsed ? undefined : brandShort)}
items={workspaceOrdered}
collapsed={collapsed}
activeTab={activeTab}
onTabChange={onTabChange}
defaultOpen={SECTION_META.workspace.defaultOpen}
pinnedCount={workspacePinned.length}
orgBadge={collapsed ? null : orgBadge}
onToggleHide={toggleHideTab}
hiddenTabs={hiddenTabs}
/>
{/* Org-local sections — only render if they have items */}
{([
{ sectionId: 'email-sends' as SectionId, orgBadged: true },
{ sectionId: 'social' as SectionId, orgBadged: false },
{ sectionId: 'intelligence' as SectionId, orgBadged: true },
{ sectionId: 'geo' as SectionId, orgBadged: true },
{ sectionId: 'pulse' as SectionId, orgBadged: true },
{ sectionId: 'congress' as SectionId, orgBadged: true },
{ sectionId: 'nexus' as SectionId, orgBadged: true },
{ sectionId: 'agents' as SectionId, orgBadged: true },
] as const).map(({ sectionId, orgBadged }) => {
const items = itemsBySection(sectionId);
if (items.length === 0) return null;
return (
<SidebarSection
key={sectionId}
sectionId={sectionId}
title={SECTION_META[sectionId].label()}
items={items}
collapsed={collapsed}
activeTab={activeTab}
onTabChange={onTabChange}
defaultOpen={SECTION_META[sectionId].defaultOpen}
orgBadge={orgBadged && !collapsed ? orgBadge : undefined}
onToggleHide={toggleHideTab}
hiddenTabs={hiddenTabs}
/>
);
})}
{/* ── UNIVERSAL divider + section — only if it has items ──────────── */}
{itemsBySection('universal').length > 0 && (
<>
<UniversalDivider collapsed={collapsed} />
<SidebarSection
sectionId="universal"
title={SECTION_META.universal.label()}
items={itemsBySection('universal')}
collapsed={collapsed}
activeTab={activeTab}
onTabChange={onTabChange}
defaultOpen={SECTION_META.universal.defaultOpen}
onToggleHide={toggleHideTab}
hiddenTabs={hiddenTabs}
/>
</>
)}
{/* Bottom spacer */}
<div style={{ height: 8 }} />
</nav>
{/* ── Impact Strip ─────────────────────────────────────────────────── */}
<ImpactStrip collapsed={collapsed} />
{/* ── Footer: Manage Tabs + Collapse ─────────────────────────────── */}
<div
style={{
padding: '8px',
borderTop: '1px solid var(--color-border)',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{/* Manage Tabs button */}
<button
onClick={() => setManageTabsOpen(true)}
title={collapsed ? 'Manage Tabs' : undefined}
aria-label="Manage Tabs"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 8,
border: 'none',
backgroundColor: 'transparent',
color: hiddenTabs.size > 0 ? 'var(--color-secondary)' : 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 12,
transition: 'background-color 0.15s, color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = hiddenTabs.size > 0 ? 'var(--color-secondary)' : 'var(--color-text-muted)';
}}
>
<SlidersHorizontal size={16} />
{!collapsed && (
<span style={{ flex: 1 }}>
Manage Tabs
{hiddenTabs.size > 0 && (
<span
style={{
marginLeft: 6,
fontSize: 10,
fontWeight: 700,
color: 'var(--color-secondary)',
}}
>
({hiddenTabs.size})
</span>
)}
</span>
)}
</button>
{/* Collapse toggle */}
<button
onClick={() => setCollapsed((c) => !c)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 8,
border: 'none',
backgroundColor: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontSize: 12,
transition: 'background-color 0.15s, color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
e.currentTarget.style.color = 'var(--color-text-secondary)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--color-text-muted)';
}}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
{!collapsed && <span>Collapse</span>}
</button>
</div>
{/* ── Manage Tabs Modal ────────────────────────────────────────────── */}
<ManageTabsModal
open={manageTabsOpen}
onClose={() => setManageTabsOpen(false)}
allTabs={allAvailableTabs}
hiddenTabs={hiddenTabs}
onToggleTab={toggleHideTab}
onResetAll={resetHiddenTabs}
/>
</aside>
</>
);
}
/* ─── SidebarButton ──────────────────────────────────────────────────────── */
function SidebarButton({
id,
label,
Icon,
isActive,
collapsed,
onClick,
kbd,
onToggleHide,
isHiddenByUser,
}: {
id: string;
label: string;
Icon: React.ElementType;
isActive: boolean;
collapsed: boolean;
onClick: () => void;
kbd?: string;
onToggleHide?: (tabId: TabId) => void;
isHiddenByUser?: boolean;
}) {
const [hovered, setHovered] = useState(false);
const handleEyeClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (onToggleHide) onToggleHide(id as TabId);
},
[onToggleHide, id],
);
return (
<button
onClick={onClick}
aria-current={isActive ? 'page' : undefined}
title={collapsed ? (kbd ? `${label} (${kbd})` : label) : undefined}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '12px 0' : '10px 12px', /* was 8px — taller target */
minHeight: 44, /* WCAG 2.5.5 touch target */
justifyContent: collapsed ? 'center' : 'flex-start',
borderRadius: 8,
border: 'none',
cursor: 'pointer',
position: 'relative',
backgroundColor: isActive
? 'rgba(16, 185, 129, 0.16)'
: hovered
? 'var(--color-surface-el)'
: 'transparent',
boxShadow: isActive
? 'inset 0 0 14px rgba(16, 185, 129, 0.08)'
: 'none',
color: isActive
? 'var(--color-text)'
: hovered
? 'var(--color-text)'
: 'var(--color-text-secondary)',
fontSize: 15, /* was 13 — more readable nav labels */
fontWeight: isActive ? 600 : 500,
transition: 'background-color 0.15s, color 0.15s, box-shadow 0.15s',
width: '100%',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{/* Active indicator bar — thicker and more prominent */}
{isActive && (
<div
style={{
position: 'absolute',
left: 0,
top: '15%',
bottom: '15%',
width: 4, /* was 3 — thicker = more visible */
borderRadius: '0 4px 4px 0',
background: 'linear-gradient(180deg, #34d399, #10b981)',
boxShadow: '0 0 8px rgba(52, 211, 153, 0.6)',
}}
aria-hidden="true"
/>
)}
<Icon
size={20} /* was 18 — easier to see */
aria-hidden="true"
style={{
color: isActive ? '#34d399' : 'currentColor',
flexShrink: 0,
transition: 'color 0.15s, transform 0.15s',
transform: hovered && !isActive ? 'scale(1.08)' : 'scale(1)',
}}
/>
{!collapsed && <span style={{ flex: 1 }}>{label}</span>}
{/* Eye toggle — shown on hover, not in collapsed mode */}
{!collapsed && hovered && onToggleHide && (
<span
role="button"
tabIndex={-1}
aria-label={isHiddenByUser ? `Show ${label}` : `Hide ${label}`}
onClick={handleEyeClick}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: 2,
borderRadius: 4,
flexShrink: 0,
color: isHiddenByUser ? 'var(--color-warning)' : 'var(--color-text-muted)',
opacity: 0.7,
transition: 'opacity 0.15s, color 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.7'; }}
>
{isHiddenByUser ? <EyeOff size={14} /> : <Eye size={14} />}
</span>
)}
{!collapsed && kbd && !hovered && (
<span className="kbd" aria-label={`Shortcut: ${kbd}`}>{kbd}</span>
)}
{collapsed && <span className="sr-only">{label}</span>}
</button>
);
}