← 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>
  );
}