← back to Norma
components/IntegrationsTab.tsx
523 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
Mail, HardDrive, Globe, Megaphone, Archive, Hash,
AlertCircle, CheckCircle2, ExternalLink, Plug,
Calendar, Layout, Zap, BarChart3, Palette, Database,
RefreshCw, TestTube, Power, ChevronDown, Search, Filter,
CreditCard, FileSpreadsheet, Users, Send, Table2, LineChart,
MessageSquare, ClipboardList, Building2, FormInput,
} from 'lucide-react';
import { useToast } from './ToastProvider';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Service {
id: string;
service_id: string;
name: string;
category: string;
status: 'connected' | 'not_connected' | 'error' | 'partial';
config: Record<string, unknown>;
metadata: Record<string, unknown>;
connected_by: string | null;
connected_at: string | null;
last_synced_at: string | null;
error_message: string | null;
updated_at: string;
}
/* ─── Service UI metadata (icons, colors, descriptions) ──────────────────── */
const SERVICE_META: Record<string, {
Icon: React.ElementType;
color: string;
description: string;
capabilities: string[];
}> = {
slack: {
Icon: Hash,
color: '#4A154B',
description: 'Team notifications, slash commands, channel posting',
capabilities: ['Webhook notifications', 'Slash commands (/norma-status)', 'Channel posting', 'Block Kit messages'],
},
clickup: {
Icon: Layout,
color: '#7B68EE',
description: 'Task management, project tracking, petition-to-task sync',
capabilities: ['Create tasks from petitions', 'Sync grant deadlines', 'Space/list management', 'Assignee mapping'],
},
google_calendar: {
Icon: Calendar,
color: '#4285F4',
description: 'Event scheduling, campaign calendar, deadline reminders',
capabilities: ['Campaign event creation', 'Grant deadline sync', 'Meeting scheduling', 'Social post scheduling'],
},
notion: {
Icon: Database,
color: '#000000',
description: 'Knowledge base, meeting notes, campaign documentation',
capabilities: ['Page creation', 'Database sync', 'Meeting notes', 'Campaign wiki'],
},
zapier: {
Icon: Zap,
color: '#FF4A00',
description: 'Workflow automation, cross-platform triggers',
capabilities: ['Automated workflows', 'Petition → email triggers', 'Grant deadline alerts', 'Data sync'],
},
google_drive: {
Icon: HardDrive,
color: '#0F9D58',
description: 'Document storage, proposal files, grant attachments',
capabilities: ['Auto-save proposals', 'Grant attachment storage', 'Shared folder management', 'Template library'],
},
google_analytics: {
Icon: BarChart3,
color: '#E37400',
description: 'Website analytics, petition page tracking, engagement metrics',
capabilities: ['Page view tracking', 'Petition conversion rates', 'Traffic source analysis', 'Campaign UTM tracking'],
},
mailchimp: {
Icon: Mail,
color: '#FFE01B',
description: 'Email campaigns, subscriber management, audience sync',
capabilities: ['Campaign creation', 'Subscriber import', 'Audience segmentation', 'Email template sync'],
},
canva: {
Icon: Palette,
color: '#00C4CC',
description: 'Design assets, social graphics, petition visuals',
capabilities: ['Design import', 'Social media graphics', 'Brand asset management', 'Template library'],
},
airtable: {
Icon: Database,
color: '#18BFFF',
description: 'Structured data, contact management, campaign tracking',
capabilities: ['Base sync', 'Contact import', 'Campaign tracker', 'Custom views'],
},
salesforce: {
Icon: Building2,
color: '#00A1E0',
description: 'Nonprofit CRM — donors, contacts, campaigns, grant tracking',
capabilities: ['Donor management', 'Campaign tracking', 'Grant pipeline', 'Constituent data', 'Free 10 NPSP licenses'],
},
google_sheets: {
Icon: FileSpreadsheet,
color: '#0F9D58',
description: 'Universal data exchange for imports, exports, shared trackers',
capabilities: ['Spreadsheet sync', 'Data import/export', 'Shared trackers', 'Formula-driven reports'],
},
action_network: {
Icon: Megaphone,
color: '#FF6B35',
description: 'Advocacy petition + email platform for grassroots organizing',
capabilities: ['Petition signatures', 'Email list sync', 'Supporter actions', 'Event RSVPs', 'Action pages'],
},
looker_studio: {
Icon: LineChart,
color: '#4285F4',
description: 'Free dashboards and data visualization from any source',
capabilities: ['Embedded dashboards', 'Cross-source analytics', 'Custom reports', 'Real-time data'],
},
twilio_sendgrid: {
Icon: Send,
color: '#1A82E2',
description: 'Transactional email + SMS for supporter outreach',
capabilities: ['Email delivery', 'SMS campaigns', 'Open/click tracking', 'Delivery analytics', 'Templates'],
},
stripe: {
Icon: CreditCard,
color: '#635BFF',
description: 'Donation processing, recurring giving, payment analytics',
capabilities: ['One-time donations', 'Recurring giving', 'Revenue reports', 'Donor receipts', 'Payment links'],
},
census_api: {
Icon: Users,
color: '#003366',
description: 'Demographic data for geo-targeted advocacy campaigns',
capabilities: ['Population by ZIP', 'Income/education data', 'Debt-by-region', 'Congressional districts', 'ACS data'],
},
fred_api: {
Icon: Table2,
color: '#003B5C',
description: 'Economic indicators — national debt stats, interest rates',
capabilities: ['Student debt totals', 'Interest rate trends', 'Default rates', 'Repayment data', 'Historical series'],
},
everyaction: {
Icon: ClipboardList,
color: '#E8490F',
description: 'Unified CRM + fundraising + digital advocacy for nonprofits',
capabilities: ['Donor CRM', 'Advocacy actions', 'Fundraising metrics', 'Digital campaigns', 'Volunteer management'],
},
typeform: {
Icon: FormInput,
color: '#262627',
description: 'Survey + intake forms for supporter data collection',
capabilities: ['Survey responses', 'Petition sign-ups', 'Intake forms', 'Conditional logic', 'Data export'],
},
};
/* ─── Category labels ────────────────────────────────────────────────────── */
const CATEGORY_LABELS: Record<string, string> = {
communication: 'Communication',
project_mgmt: 'Project Management',
productivity: 'Productivity',
automation: 'Automation',
analytics: 'Analytics',
design: 'Design',
crm: 'CRM',
advocacy: 'Advocacy',
payments: 'Payments',
data: 'Data Sources',
};
/* ─── Status badge ───────────────────────────────────────────────────────── */
function IntStatusBadge({ status }: { status: string }) {
const configs: Record<string, { label: string; color: string; bg: string; border: string; Icon: React.ElementType }> = {
connected: { label: 'Connected', color: '#22c55e', bg: 'rgba(34,197,94,0.12)', border: 'rgba(34,197,94,0.3)', Icon: CheckCircle2 },
partial: { label: 'Partial', color: '#f59e0b', bg: 'rgba(245,158,11,0.12)', border: 'rgba(245,158,11,0.3)', Icon: AlertCircle },
not_connected: { label: 'Not Connected', color: '#94a3b8', bg: 'rgba(148,163,184,0.08)', border: 'rgba(148,163,184,0.2)', Icon: Power },
error: { label: 'Error', color: '#ef4444', bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.3)', Icon: AlertCircle },
};
const cfg = configs[status] || configs.not_connected;
return (
<span className="badge" style={{ color: cfg.color, backgroundColor: cfg.bg, border: `1px solid ${cfg.border}`, gap: 4 }}>
<cfg.Icon size={11} /> {cfg.label}
</span>
);
}
function timeAgo(dateStr: string | null): string {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
/* ─── Service Card ───────────────────────────────────────────────────────── */
function ServiceCard({ service, index, onToggle, onTest }: {
service: Service;
index: number;
onToggle: (id: string, action: 'connect' | 'disconnect') => void;
onTest: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const meta = SERVICE_META[service.service_id] || {
Icon: Plug, color: '#666', description: service.name, capabilities: [],
};
const { Icon, color, description, capabilities } = meta;
const connected = service.status === 'connected';
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.04 }}
className="card"
style={{
borderColor: connected ? 'rgba(34,197,94,0.25)' : service.status === 'error' ? 'rgba(239,68,68,0.3)' : 'var(--color-border)',
cursor: 'pointer',
transition: 'border-color 0.15s ease',
}}
onClick={() => setExpanded((v) => !v)}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div style={{
width: 44, height: 44, borderRadius: 'var(--radius-md)',
backgroundColor: `${color}14`, border: `1px solid ${color}28`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<Icon size={22} style={{ color }} />
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="flex items-center gap-2 flex-wrap">
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>{service.name}</span>
<IntStatusBadge status={service.status} />
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 600 }}>
{CATEGORY_LABELS[service.category] || service.category}
</span>
<ChevronDown size={14} style={{
marginLeft: 'auto', color: 'var(--color-text-muted)',
transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s ease',
}} />
</div>
<p style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginTop: 2 }}>{description}</p>
{/* Metadata row */}
{connected && (
<div className="flex items-center gap-4" style={{ marginTop: 6, fontSize: 11, color: 'var(--color-text-muted)' }}>
{service.metadata.workspace_name ? <span>{service.metadata.workspace_name as string}</span> : null}
{service.last_synced_at && <span>Synced {timeAgo(service.last_synced_at)}</span>}
{service.connected_at && <span>Connected {timeAgo(service.connected_at)}</span>}
</div>
)}
{service.status === 'error' && service.error_message && (
<p style={{ fontSize: 11, color: 'var(--color-error)', marginTop: 4 }}>{service.error_message}</p>
)}
{/* Expanded section */}
{expanded && (
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} transition={{ duration: 0.15 }} style={{ overflow: 'hidden' }}>
{/* Capabilities */}
{capabilities.length > 0 && (
<div style={{ marginTop: 12 }}>
<p style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Capabilities
</p>
<div className="flex flex-wrap gap-1">
{capabilities.map((cap) => (
<span key={cap} style={{
fontSize: 11, padding: '2px 8px', borderRadius: 12,
backgroundColor: connected ? 'rgba(34,197,94,0.08)' : 'var(--color-surface-el)',
color: connected ? '#22c55e' : 'var(--color-text-secondary)',
border: `1px solid ${connected ? 'rgba(34,197,94,0.2)' : 'var(--color-border)'}`,
}}>
{cap}
</span>
))}
</div>
</div>
)}
{/* Config details */}
{connected && Object.keys(service.config).length > 0 && (
<div style={{ marginTop: 12, padding: '8px 10px', borderRadius: 8, backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 4 }}>Configuration</p>
{Object.entries(service.config).map(([key, val]) => (
<div key={key} style={{ fontSize: 11, color: 'var(--color-text-secondary)', display: 'flex', gap: 8, marginBottom: 2 }}>
<span style={{ fontWeight: 600, minWidth: 100 }}>{key.replace(/_/g, ' ')}:</span>
<span>{typeof val === 'object' ? JSON.stringify(val) : String(val)}</span>
</div>
))}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2" style={{ marginTop: 12 }}>
{connected ? (
<>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 12 }}
onClick={(e) => { e.stopPropagation(); onTest(service.service_id); }}
>
<TestTube size={13} /> Test Connection
</button>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 12, color: 'var(--color-error)' }}
onClick={(e) => { e.stopPropagation(); onToggle(service.service_id, 'disconnect'); }}
>
<Power size={13} /> Disconnect
</button>
</>
) : (
<button
className="btn btn-primary btn-sm"
style={{ fontSize: 12 }}
onClick={(e) => { e.stopPropagation(); onToggle(service.service_id, 'connect'); }}
>
<Plug size={13} /> Connect
</button>
)}
</div>
</motion.div>
)}
</div>
</div>
</motion.div>
);
}
/* ─── IntegrationsTab (DB-backed) ────────────────────────────────────────── */
export default function IntegrationsTab() {
const { addToast } = useToast();
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [filterCat, setFilterCat] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
const [search, setSearch] = useState('');
const loadServices = useCallback(async () => {
try {
const res = await fetch('/api/integrations', { credentials: 'include' });
if (!res.ok) throw new Error();
const data = await res.json();
setServices(data.services || []);
} catch {
addToast('Failed to load integrations', 'error');
} finally {
setLoading(false);
}
}, [addToast]);
useEffect(() => { loadServices(); }, [loadServices]);
async function toggleService(serviceId: string, action: 'connect' | 'disconnect') {
try {
const res = await fetch('/api/integrations', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
service_id: serviceId,
status: action === 'connect' ? 'connected' : 'not_connected',
}),
});
if (!res.ok) throw new Error();
addToast(`${serviceId} ${action === 'connect' ? 'connected' : 'disconnected'}`, 'success');
loadServices();
} catch {
addToast(`Failed to ${action} ${serviceId}`, 'error');
}
}
async function testService(serviceId: string) {
addToast(`Testing ${serviceId}...`, 'info');
try {
const res = await fetch('/api/integrations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ service_id: serviceId, action: 'test' }),
});
const data = await res.json();
addToast(data.message || (data.ok ? 'Connection OK' : 'Test failed'), data.ok ? 'success' : 'error');
loadServices();
} catch {
addToast('Connection test failed', 'error');
}
}
const connectedCount = services.filter((s) => s.status === 'connected').length;
const totalCount = services.length;
const categories = ['all', ...new Set(services.map((s) => s.category))];
const filtered = services.filter((s) => {
if (filterCat !== 'all' && s.category !== filterCat) return false;
if (filterStatus !== 'all' && s.status !== filterStatus) return false;
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<div className="spinner" style={{ width: 28, height: 28 }} />
</div>
);
}
return (
<div style={{ padding: '20px 24px', maxWidth: 800, margin: '0 auto' }}>
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<div className="flex items-center gap-3">
<div style={{
width: 36, height: 36, borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(16,185,129,0.15), rgba(99,102,241,0.15))',
border: '1px solid rgba(16,185,129,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Plug size={18} style={{ color: 'var(--color-primary)' }} />
</div>
<div>
<h1 style={{ fontSize: 18, fontWeight: 700, color: 'var(--color-text)', margin: 0 }}>Connected Services</h1>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
{connectedCount} of {totalCount} connected
</p>
</div>
</div>
<button className="btn btn-ghost btn-sm" onClick={loadServices}>
<RefreshCw size={14} />
</button>
</div>
{/* Progress bar */}
<div style={{
padding: '12px 16px', borderRadius: 'var(--radius-md)', marginBottom: 16,
background: connectedCount >= totalCount * 0.5 ? 'rgba(34,197,94,0.06)' : 'rgba(245,158,11,0.06)',
border: `1px solid ${connectedCount >= totalCount * 0.5 ? 'rgba(34,197,94,0.2)' : 'rgba(245,158,11,0.2)'}`,
}}>
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
{connectedCount === totalCount ? 'All services connected' : `${totalCount - connectedCount} services need setup`}
</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)' }}>{connectedCount}/{totalCount}</span>
</div>
<div style={{ width: '100%', height: 6, borderRadius: 3, backgroundColor: 'var(--color-surface-el)', overflow: 'hidden' }}>
<div style={{
width: `${(connectedCount / Math.max(totalCount, 1)) * 100}%`,
height: '100%', borderRadius: 3,
backgroundColor: connectedCount === totalCount ? '#22c55e' : connectedCount >= totalCount * 0.5 ? '#10b981' : '#f59e0b',
transition: 'width 0.3s ease',
}} />
</div>
</div>
{/* Filter bar */}
<div className="flex items-center gap-2 flex-wrap" style={{ marginBottom: 16 }}>
<div style={{ position: 'relative', flex: 1, minWidth: 150 }}>
<Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
<input
className="input"
placeholder="Search services..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ paddingLeft: 32, fontSize: 12, minHeight: 'unset', padding: '7px 12px 7px 32px' }}
/>
</div>
<select
className="input"
value={filterCat}
onChange={(e) => setFilterCat(e.target.value)}
style={{ fontSize: 12, minHeight: 'unset', padding: '7px 12px', width: 'auto' }}
>
{categories.map((c) => (
<option key={c} value={c}>{c === 'all' ? 'All Categories' : CATEGORY_LABELS[c] || c}</option>
))}
</select>
<select
className="input"
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
style={{ fontSize: 12, minHeight: 'unset', padding: '7px 12px', width: 'auto' }}
>
<option value="all">All Statuses</option>
<option value="connected">Connected</option>
<option value="not_connected">Not Connected</option>
<option value="error">Error</option>
</select>
</div>
{/* Service cards */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((service, i) => (
<ServiceCard
key={service.id}
service={service}
index={i}
onToggle={toggleService}
onTest={testService}
/>
))}
{filtered.length === 0 && (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-muted)' }}>
<Filter size={32} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<p style={{ fontSize: 13 }}>No services match your filters</p>
</div>
)}
</div>
</div>
);
}