← back to Norma
components/NikkiShell.tsx
392 lines
'use client';
import { useState, useEffect } from 'react';
import { LogOut, Menu, LayoutDashboard, Newspaper, Megaphone, Award, MoreHorizontal } from 'lucide-react';
import GlobalSearch from './GlobalSearch';
import { useAuth } from './AuthProvider';
import { useOrg } from './OrgProvider';
import Sidebar, { type TabId } from './Sidebar';
import NikkiDashboard from './nikki/NikkiDashboard';
import DraftsTab from './drafts/DraftsTab';
import PetitionsTab from './petitions/PetitionsTab';
import GrantsTab from './grants/GrantsTab';
import PipelineTab from './pipeline/PipelineTab';
import StatementsTab from './statements/StatementsTab';
import DonationsTab from './donations/DonationsTab';
import NewsTab from './NewsTab';
import CommunityTab from './community/CommunityTab';
import TopicRadar from './radar/TopicRadar';
import PetitionCreator from './petitions/PetitionCreator';
import TrendingPetitions from './petitions/TrendingPetitions';
import LiveNews from './news/LiveNews';
import EmailSendsTab from './email-sends/EmailSendsTab';
import EmailAnalyzer from './email-analyzer/EmailAnalyzer';
import SavedAnalyses from './email-analyzer/SavedAnalyses';
import GmailCRMTab from './gmail/GmailCRMTab';
import ContactsTab from './contacts/ContactsTab';
import JournalistHub from './journalists/JournalistHub';
import CongressTab from './congress/CongressTab';
import PowerIntelligenceTab from './intelligence/PowerIntelligenceTab';
import InTheNewsTab from './intelligence/InTheNewsTab';
import ChatPanel from './ChatPanel';
import SettingsTab from './settings/SettingsTab';
/* ─── NikkiShell — org-locked staff shell ────────────────────────────────── */
export default function NikkiShell() {
const { user, logout } = useAuth();
const { org } = useOrg();
const [activeTab, setActiveTab] = useState<TabId>('email-sends');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [journalistNavId, setJournalistNavId] = useState<string | null>(null);
function handleTabChange(tab: TabId) {
setActiveTab(tab);
setSidebarOpen(false);
setJournalistNavId(null);
}
/* ── Keyboard shortcuts ──────────────────────────────────────────────── */
useEffect(() => {
const KBD_NAV: Record<string, TabId> = {
h: 'dashboard',
d: 'drafts',
p: 'petitions',
r: 'topic-radar',
l: 'pipeline',
n: 'contacts',
};
function handleKeyDown(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if ((e.target as HTMLElement)?.isContentEditable) return;
const tab = KBD_NAV[e.key.toLowerCase()];
if (tab) {
e.preventDefault();
handleTabChange(tab);
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
function renderPanel() {
if (!org) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-6 max-w-lg mx-auto">
<div className="spinner" />
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Loading organization...
</p>
</div>
);
}
switch (activeTab) {
case 'dashboard': return <NikkiDashboard onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'drafts': return <DraftsTab onNavigateToSettings={() => {}} />;
case 'news': return <NewsTab onNavigate={(tab) => handleTabChange(tab as TabId)} onNavigateToJournalist={(name) => { setJournalistNavId(`name:${name}`); setActiveTab('journalists'); }} />;
case 'petitions': return <PetitionsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'grants': return <GrantsTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'pipeline': return <PipelineTab />;
case 'statements': return <StatementsTab />;
case 'donations': return <DonationsTab />;
case 'email-sends': return <EmailSendsTab />;
case 'email-analyzer': return <EmailAnalyzer />;
case 'email-analyzer-saved':
return (
<SavedAnalyses
onOpen={(sid) => {
try { sessionStorage.setItem('norma-analyzer-load-session', sid); } catch { /* noop */ }
handleTabChange('email-analyzer');
}}
/>
);
case 'gmail-crm': return <GmailCRMTab />;
case 'contacts': return <ContactsTab />;
case 'journalists': return <JournalistHub initialJournalistId={journalistNavId} />;
case 'community': return <CommunityTab />;
case 'topic-radar': return <TopicRadar onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'petition-creator': return <PetitionCreator />;
case 'trending-petitions': return <TrendingPetitions />;
case 'live-news': return <LiveNews />;
case 'congress': return <CongressTab onNavigate={(tab) => handleTabChange(tab as TabId)} />;
case 'power-intelligence': return <PowerIntelligenceTab />;
case 'in-the-news': return <InTheNewsTab />;
case 'settings': return <SettingsTab />;
default:
return (
<div className="flex flex-col items-center justify-center py-20 gap-3" style={{ color: 'var(--color-text-muted)' }}>
<p className="text-lg font-semibold">Tab not found</p>
<p className="text-sm">The selected view is not available for staff accounts.</p>
</div>
);
}
}
const TAB_LABELS: Partial<Record<TabId, string>> = {
dashboard: 'Dashboard',
drafts: 'Drafts',
news: 'News Feed',
petitions: 'Petitions',
grants: 'Grants',
pipeline: 'Pipeline',
statements: 'Statements',
donations: 'Donations',
'email-sends': 'Email Center',
'email-analyzer': 'Email Analyzer',
'email-analyzer-saved': 'Saved Analyses',
'gmail-crm': 'Gmail CRM',
'power-intelligence': 'Power Intel',
'in-the-news': 'In the News',
contacts: 'Network',
journalists: 'Journalists',
community: 'Community',
'topic-radar': 'Topic Radar',
'petition-creator': 'Create Petition',
'trending-petitions': 'Trending Petitions',
'live-news': 'Live News',
congress: 'Congress',
};
const brandColor = org?.brand_color || '#10b981';
return (
<div
className="flex"
style={{ backgroundColor: 'var(--color-bg)', minHeight: '100vh' }}
>
{/* ── Skip Navigation ───────────────────────────────────────────────── */}
<a
href="#main-content"
className="sr-only focus:not-sr-only"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 9999,
padding: '8px 16px',
backgroundColor: 'var(--color-primary)',
color: '#fff',
borderRadius: 'var(--radius-md)',
fontSize: '0.875rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
Skip to main content
</a>
{/* ── Left Sidebar ─────────────────────────────────────────────────── */}
<Sidebar
activeTab={activeTab}
onTabChange={handleTabChange}
isOpen={sidebarOpen}
onToggle={() => setSidebarOpen(p => !p)}
role="staff"
/>
{/* ── Right: Header + Content ──────────────────────────────────────── */}
<div className="flex flex-col flex-1 min-w-0">
{/* ── Top Header Bar ─────────────────────────────────────────────── */}
<header
className="flex items-center justify-between px-5 h-14 shrink-0"
style={{
backgroundColor: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
}}
>
{/* Left: hamburger + section name */}
<div className="flex items-center gap-2">
<button
className="btn btn-ghost btn-sm"
onClick={() => setSidebarOpen(p => !p)}
style={{ display: 'none' }}
aria-label="Toggle menu"
id="mobile-menu-btn"
>
<Menu size={18} />
</button>
<h1
className="text-sm font-semibold"
style={{ color: 'var(--color-text)' }}
>
{TAB_LABELS[activeTab] || activeTab}
</h1>
</div>
{/* Center: global search */}
<GlobalSearch onNavigate={(tab, id) => {
if (tab === 'journalists' && id) {
setJournalistNavId(id);
}
setActiveTab(tab as TabId);
setSidebarOpen(false);
}} />
{/* Right: org name + user + logout */}
<div className="flex items-center gap-3">
{/* Org badge — fixed, not switchable */}
{org && (
<div className="flex items-center gap-2">
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
background: `linear-gradient(135deg, ${brandColor}, ${brandColor}cc)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 800 }}>
{(org.short_name || org.org_name.slice(0, 2)).slice(0, 3)}
</span>
</div>
<span
className="text-xs hidden sm:inline font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
{org.org_name}
</span>
</div>
)}
{user && (
<span
className="text-xs hidden sm:inline"
style={{ color: 'var(--color-text-muted)' }}
>
{user}
</span>
)}
<button
onClick={logout}
className="btn btn-ghost btn-sm"
aria-label="Sign out"
title="Sign out"
>
<LogOut size={15} aria-hidden="true" />
<span className="hidden sm:inline">Sign out</span>
</button>
</div>
</header>
{/* ── Main Content ───────────────────────────────────────────────── */}
<main id="main-content" className="flex-1 overflow-auto mobile-main-content">
{renderPanel()}
</main>
</div>
<ChatPanel />
{/* ── Mobile Bottom Navigation ─────────────────────────────────────── */}
<nav
className="mobile-bottom-nav"
aria-label="Mobile bottom navigation"
style={{
display: 'none',
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 56,
zIndex: 50,
backgroundColor: 'var(--color-surface)',
borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.08)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
height: '100%',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
}}
>
{([
{ id: 'dashboard' as TabId, label: 'Dashboard', Icon: LayoutDashboard },
{ id: 'news' as TabId, label: 'News', Icon: Newspaper },
{ id: 'petitions' as TabId, label: 'Petitions', Icon: Megaphone },
{ id: 'grants' as TabId, label: 'Grants', Icon: Award },
] as const).map(({ id, label, Icon }) => {
const isActive = activeTab === id;
return (
<button
key={id}
type="button"
onClick={() => handleTabChange(id)}
aria-current={isActive ? 'page' : undefined}
aria-label={label}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
minHeight: 44,
height: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
color: isActive ? 'var(--color-primary)' : 'var(--color-text-muted)',
transition: 'color 0.15s',
position: 'relative',
}}
>
{isActive && (
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: '20%',
right: '20%',
height: 2,
borderRadius: '0 0 2px 2px',
background: 'linear-gradient(90deg, #34d399, #10b981)',
boxShadow: '0 0 6px rgba(52,211,153,0.6)',
}}
/>
)}
<Icon size={20} aria-hidden="true" />
<span style={{ fontSize: 10, fontWeight: isActive ? 700 : 500, lineHeight: 1 }}>
{label}
</span>
</button>
);
})}
<button
type="button"
onClick={() => setSidebarOpen(true)}
aria-label="Open navigation menu"
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
minHeight: 44,
height: '100%',
border: 'none',
background: 'none',
cursor: 'pointer',
color: 'var(--color-text-muted)',
transition: 'color 0.15s',
}}
>
<MoreHorizontal size={20} aria-hidden="true" />
<span style={{ fontSize: 10, fontWeight: 500, lineHeight: 1 }}>More</span>
</button>
</div>
</nav>
</div>
);
}