← back to Freddy

components/solicitor/SolicitorTab.tsx

426 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { SkeletonList } from '../Skeleton';
import {
  Send, Sparkles, Loader2, Search, Users, Mail,
  FileText, User, Building2, Phone, Linkedin,
  Star, MessageSquare, X, Copy, Check,
  AlertTriangle, RefreshCw,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

interface Contact {
  id: string;
  contact_type: string;
  name: string;
  title: string | null;
  organization: string | null;
  email: string | null;
  phone: string | null;
  linkedin_url: string | null;
  city: string | null;
  state: string | null;
  source: string;
  relevance_score: number;
  ai_pitch: string | null;
  solicited: boolean;
  solicited_at: string | null;
  response: string | null;
  notes: string | null;
  created_at: string;
}

const SORT_OPTIONS = [
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'relevance_desc', label: 'Most Relevant' },
  { value: 'name_asc', label: 'Name A-Z' },
  { value: 'response', label: 'Response Status' },
];

const SORT_CONFIGS: Record<string, SortConfig> = {
  date_desc:      { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc:       { key: 'created_at', direction: 'asc', type: 'date' },
  relevance_desc: { key: 'relevance_score', direction: 'desc', type: 'number' },
  name_asc:       { key: 'name', direction: 'asc', type: 'string' },
  response:       { key: 'response', direction: 'asc', type: 'string' },
};

const CONTACT_TYPES = ['all', 'nonprofit_leader', 'grant_officer', 'board_member', 'program_director', 'government', 'media'];
const RESPONSE_STATUSES = ['none', 'interested', 'meeting_set', 'signed', 'declined'];

const fadeIn = {
  hidden: { opacity: 0, y: 20 },
  visible: (i: number) => ({
    opacity: 1, y: 0,
    transition: { delay: i * 0.04, duration: 0.35 },
  }),
};

export default function SolicitorTab() {
  const { addToast } = useToast();
  const [contacts, setContacts] = useState<Contact[]>([]);
  const [loading, setLoading] = useState(true);
  const [discovering, setDiscovering] = useState(false);
  const [typeFilter, setTypeFilter] = useState('all');
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 300);
  const [letterContact, setLetterContact] = useState<Contact | null>(null);
  const [generatedLetter, setGeneratedLetter] = useState('');
  const [generatingLetter, setGeneratingLetter] = useState(false);
  const [letterType, setLetterType] = useState('nonprofit_pitch');
  const [copied, setCopied] = useState(false);
  const [sortBy, setSortBy] = useState('relevance_desc');
  const sortedContacts = useClientSort(contacts, sortBy, SORT_CONFIGS);

  const fetchContacts = useCallback(async () => {
    try {
      const params = new URLSearchParams();
      if (typeFilter !== 'all') params.set('type', typeFilter);
      const res = await fetch(`/api/contacts?${params}`, { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setContacts(data.contacts || []);
      }
    } catch (err) {
      console.error('Failed to fetch contacts:', err);
    } finally {
      setLoading(false);
    }
  }, [typeFilter]);

  useEffect(() => { fetchContacts(); }, [fetchContacts]);

  /* ── Escape key handler ───────────────────────────────────────── */
  useEffect(() => {
    if (!letterContact) return;
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { setLetterContact(null); setGeneratedLetter(''); }
    };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [letterContact]);

  const handleDiscover = async () => {
    setDiscovering(true);
    try {
      const res = await fetch('/api/contacts/discover', {
        method: 'POST',
        credentials: 'include',
      });
      if (res.ok) {
        await fetchContacts();
        addToast('Contacts discovered', 'success');
      } else {
        addToast('Discovery failed', 'error');
      }
    } catch (err) {
      console.error('Discover error:', err);
      addToast('Discovery failed', 'error');
    } finally {
      setDiscovering(false);
    }
  };

  const handleGenerateLetter = async (contact: Contact, type: string) => {
    setLetterContact(contact);
    setLetterType(type);
    setGeneratingLetter(true);
    setGeneratedLetter('');
    try {
      const res = await fetch('/api/contacts/generate-letter', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ contact_id: contact.id, letter_type: type }),
      });
      if (res.ok) {
        const data = await res.json();
        setGeneratedLetter(data.letter || '');
        addToast('Letter generated', 'success');
      } else {
        addToast('Letter generation failed', 'error');
      }
    } catch (err) {
      console.error('Generate letter error:', err);
      addToast('Letter generation failed', 'error');
    } finally {
      setGeneratingLetter(false);
    }
  };

  const handleUpdateResponse = async (contactId: string, response: string) => {
    try {
      await fetch(`/api/contacts`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ id: contactId, response, solicited: true }),
      });
      await fetchContacts();
      addToast('Response updated', 'success');
    } catch (err) {
      console.error('Update error:', err);
      addToast('Update failed', 'error');
    }
  };

  const copyToClipboard = async () => {
    await navigator.clipboard.writeText(generatedLetter);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
    addToast('Letter copied to clipboard', 'success');
  };

  const filtered = sortedContacts.filter(c =>
    !debouncedSearch || c.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
    (c.organization && c.organization.toLowerCase().includes(debouncedSearch.toLowerCase()))
  );

  const responseColor = (r: string | null) => {
    switch (r) {
      case 'interested': return 'badge-blue';
      case 'meeting_set': return 'badge-warning';
      case 'signed': return 'badge-success';
      case 'declined': return 'badge-error';
      default: return 'badge-amber';
    }
  };

  const typeLabel = (t: string) => t.replace('_', ' ');

  return (
    <div className="p-6 space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between flex-wrap gap-3">
        <div>
          <h2 className="text-xl font-bold flex items-center gap-2" style={{ color: 'var(--color-text)' }}>
            <Send size={20} style={{ color: '#d97706' }} />
            Solicitor
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            Contact harvesting and outreach letter generation for both parties
          </p>
        </div>
        <div className="flex items-center gap-2">
          <button onClick={handleDiscover} disabled={discovering} className="btn btn-primary">
            {discovering ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
            {discovering ? 'Discovering...' : 'AI Discover Contacts'}
          </button>
          <button onClick={fetchContacts} className="btn btn-secondary btn-sm">
            <RefreshCw size={14} />
          </button>
        </div>
      </div>

      {/* Fee Structure Banner */}
      <div className="card p-4" style={{ borderColor: 'rgba(217, 119, 6, 0.3)', background: 'linear-gradient(135deg, rgba(217, 119, 6, 0.05), rgba(251, 191, 36, 0.02))' }}>
        <div className="flex items-start gap-3">
          <AlertTriangle size={18} style={{ color: '#fbbf24', flexShrink: 0, marginTop: 2 }} />
          <div>
            <h4 className="text-sm font-semibold mb-2" style={{ color: '#fbbf24' }}>Fee Structure</h4>
            <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
              <div>
                <span className="font-bold" style={{ color: 'var(--color-text)' }}>Introduction Fee: $750 flat</span>
                <br />Paid by requesting party at time of introduction
              </div>
              <div>
                <span className="font-bold" style={{ color: 'var(--color-text)' }}>Success Fee: 5% of grant amount</span>
                <br />Paid by non-profit from grant funds, only if awarded
              </div>
              <div>
                <span className="font-bold" style={{ color: 'var(--color-text)' }}>No upfront costs for granters</span>
                <br />Granters explore the platform at no charge
              </div>
            </div>
          </div>
        </div>
      </div>

      {/* Filters */}
      <div className="flex items-center gap-3 flex-wrap">
        <div className="relative flex-1 max-w-xs">
          <Search size={14} style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }} />
          <input className="input pl-8" placeholder="Search contacts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} />
        </div>
        <SortDropdown options={SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
      </div>

      {/* Contact type pills */}
      <div className="flex items-center gap-2 flex-wrap">
        {CONTACT_TYPES.map((t) => (
          <button
            key={t}
            onClick={() => setTypeFilter(t)}
            className="btn btn-sm"
            style={{
              backgroundColor: typeFilter === t ? 'rgba(217, 119, 6, 0.2)' : 'var(--color-surface-el)',
              color: typeFilter === t ? '#fbbf24' : 'var(--color-text-secondary)',
              borderColor: typeFilter === t ? 'rgba(217, 119, 6, 0.3)' : 'var(--color-border)',
            }}
          >
            {t === 'all' ? 'All' : typeLabel(t)}
          </button>
        ))}
      </div>

      {/* Contacts list */}
      {loading ? (
        <SkeletonList count={4} />
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <AnimatePresence>
            {filtered.map((contact, i) => (
              <motion.div key={contact.id} custom={i} initial="hidden" animate="visible" variants={fadeIn} className="card p-4 flex flex-col gap-3">
                <div className="flex items-start justify-between">
                  <div className="flex items-center gap-3">
                    <div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}>
                      <User size={18} style={{ color: 'var(--color-text-muted)' }} />
                    </div>
                    <div>
                      <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{contact.name}</h3>
                      {contact.title && <p className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>{contact.title}</p>}
                      {contact.organization && (
                        <p className="text-xs flex items-center gap-1" style={{ color: 'var(--color-text-muted)' }}>
                          <Building2 size={10} /> {contact.organization}
                        </p>
                      )}
                    </div>
                  </div>
                  <div className="flex flex-col items-end gap-1">
                    <span className={`badge ${responseColor(contact.response)} text-xs`}>
                      {contact.response || 'none'}
                    </span>
                    <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{typeLabel(contact.contact_type)}</span>
                  </div>
                </div>

                {/* Contact info */}
                <div className="flex items-center gap-3 flex-wrap text-xs" style={{ color: 'var(--color-text-muted)' }}>
                  {contact.email && (
                    <span className="flex items-center gap-1"><Mail size={10} /> {contact.email}</span>
                  )}
                  {contact.phone && (
                    <span className="flex items-center gap-1"><Phone size={10} /> {contact.phone}</span>
                  )}
                  {contact.linkedin_url && (
                    <a href={contact.linkedin_url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1" style={{ color: '#60a5fa' }}>
                      <Linkedin size={10} /> LinkedIn
                    </a>
                  )}
                </div>

                {/* Relevance score */}
                <div className="flex items-center gap-2">
                  <Star size={12} style={{ color: '#fbbf24' }} />
                  <div className="flex-1">
                    <div className="score-bar">
                      <div className="score-bar-fill" style={{ width: `${(contact.relevance_score || 0) * 100}%` }} />
                    </div>
                  </div>
                  <span className="text-xs font-bold" style={{ color: '#fbbf24' }}>{((contact.relevance_score || 0) * 100).toFixed(0)}%</span>
                </div>

                {/* AI pitch */}
                {contact.ai_pitch && (
                  <p className="text-xs p-2 rounded" style={{ backgroundColor: 'var(--color-surface-el)', color: 'var(--color-text-secondary)', borderLeft: '2px solid #c084fc' }}>
                    {contact.ai_pitch}
                  </p>
                )}

                {/* Actions */}
                <div className="flex items-center gap-2 flex-wrap pt-1">
                  <button onClick={() => handleGenerateLetter(contact, 'nonprofit_pitch')} className="btn btn-sm btn-secondary">
                    <FileText size={12} /> NP Pitch
                  </button>
                  <button onClick={() => handleGenerateLetter(contact, 'granter_pitch')} className="btn btn-sm btn-secondary">
                    <FileText size={12} /> Granter Pitch
                  </button>
                  <button onClick={() => handleGenerateLetter(contact, 'introduction')} className="btn btn-sm btn-secondary">
                    <MessageSquare size={12} /> Intro
                  </button>
                  <button onClick={() => handleGenerateLetter(contact, 'follow_up')} className="btn btn-sm btn-secondary">
                    <Send size={12} /> Follow-Up
                  </button>

                  {/* Response status dropdown */}
                  <select
                    className="input btn-sm"
                    style={{ width: 'auto', fontSize: '0.75rem', padding: '2px 6px' }}
                    value={contact.response || 'none'}
                    onChange={(e) => handleUpdateResponse(contact.id, e.target.value)}
                  >
                    {RESPONSE_STATUSES.map(s => (
                      <option key={s} value={s}>{s === 'none' ? 'No Response' : s.replace('_', ' ')}</option>
                    ))}
                  </select>
                </div>
              </motion.div>
            ))}
          </AnimatePresence>
        </div>
      )}

      {!loading && filtered.length === 0 && (
        <div className="text-center py-12">
          <Users size={32} style={{ color: 'var(--color-text-muted)', margin: '0 auto 12px' }} />
          <p style={{ color: 'var(--color-text-muted)' }}>
            No contacts yet. Click "AI Discover Contacts" to find nonprofit leaders and grant officers.
          </p>
        </div>
      )}

      {/* Letter Generation Modal */}
      {letterContact && (
        <div className="modal-overlay" onClick={() => { setLetterContact(null); setGeneratedLetter(''); }}>
          <motion.div
            initial={{ opacity: 0, scale: 0.95 }}
            animate={{ opacity: 1, scale: 1 }}
            className="modal-content"
            style={{ maxWidth: '40rem' }}
            onClick={(e) => e.stopPropagation()}
          >
            <div className="flex items-center justify-between mb-4">
              <div>
                <h3 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
                  Generated Letter
                </h3>
                <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
                  To: {letterContact.name} &mdash; Type: {letterType.replace('_', ' ')}
                </p>
              </div>
              <button onClick={() => { setLetterContact(null); setGeneratedLetter(''); }} className="btn btn-ghost btn-sm"><X size={16} /></button>
            </div>

            {generatingLetter ? (
              <div className="flex items-center justify-center py-12 gap-2">
                <Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
                <span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>Generating personalized letter...</span>
              </div>
            ) : generatedLetter ? (
              <div className="space-y-3">
                <div className="letter-preview">{generatedLetter}</div>
                <div className="flex justify-end gap-2">
                  <button onClick={copyToClipboard} className="btn btn-secondary">
                    {copied ? <Check size={14} style={{ color: '#22c55e' }} /> : <Copy size={14} />}
                    {copied ? 'Copied!' : 'Copy'}
                  </button>
                </div>
              </div>
            ) : (
              <p className="text-sm text-center py-8" style={{ color: 'var(--color-text-muted)' }}>
                No letter generated. Try again.
              </p>
            )}
          </motion.div>
        </div>
      )}
    </div>
  );
}