← back to Grant

components/outreach/OutreachTab.tsx

634 lines

'use client';

import { useState, useEffect, useCallback, useRef } from 'react';
import { SkeletonList } from '../Skeleton';
import {
  Megaphone,
  Mail,
  FileText,
  Heart,
  ArrowRight,
  Send,
  Plus,
  Sparkles,
  Loader2,
  X,
  Copy,
  ExternalLink,
  ChevronDown,
  ChevronUp,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useAuth } from '../AuthProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

const OUTREACH_SORT_OPTIONS = [
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'type', label: 'Type A-Z' },
];

const OUTREACH_SORT_CONFIGS: Record<string, SortConfig> = {
  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
  type: { key: 'template_type', direction: 'asc', type: 'string' },
};

/* ─── Types ──────────────────────────────────────────────────────────────── */
interface OutreachTemplate {
  id: string;
  template_type: string;
  target_type: string | null;
  title: string;
  subject: string | null;
  body_html: string;
  body_text: string | null;
  is_ai_generated: boolean;
  version: number;
  created_at: string;
}

const TEMPLATE_TYPES: { Icon: React.ElementType; label: string; desc: string; type: string }[] = [
  { Icon: Mail, label: 'Meeting Request', desc: 'Request a meeting with a funder or partner', type: 'meeting_request' },
  { Icon: FileText, label: 'One-Pager', desc: 'Generate a concise organization summary', type: 'one_pager' },
  { Icon: Heart, label: 'Thank You', desc: 'Send gratitude to donors and supporters', type: 'thank_you' },
  { Icon: ArrowRight, label: 'Follow Up', desc: 'Follow up on prior conversations', type: 'follow_up' },
  { Icon: Send, label: 'Introduction', desc: 'Introduce your organization to a new contact', type: 'introduction' },
  { Icon: Megaphone, label: 'Donation Ask', desc: 'Request financial support from donors', type: 'donation_ask' },
];

const TYPE_LABELS: Record<string, string> = {
  meeting_request: 'Meeting Request',
  one_pager: 'One-Pager',
  thank_you: 'Thank You',
  follow_up: 'Follow Up',
  introduction: 'Introduction',
  donation_ask: 'Donation Ask',
};

const TARGET_LABELS: Record<string, string> = {
  official: 'Official',
  corporation: 'Corporation',
  nonprofit: 'Nonprofit',
  donor: 'Donor',
  general: 'General',
};

const TYPE_BADGE_COLORS: Record<string, string> = {
  meeting_request: '#3b82f6',
  one_pager: '#8b5cf6',
  thank_you: '#ec4899',
  follow_up: '#f59e0b',
  introduction: '#059669',
  donation_ask: '#ef4444',
};

function formatDate(d: string): string {
  return new Date(d).toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
  });
}

function stripHtml(html: string): string {
  return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}

/* ─── Main Component ─────────────────────────────────────────────────────── */
export default function OutreachTab() {
  const [templates, setTemplates] = useState<OutreachTemplate[]>([]);
  const [loading, setLoading] = useState(true);
  const [showGenerateModal, setShowGenerateModal] = useState<string | null>(null);
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const [sortBy, setSortBy] = useState('date_desc');
  const sortedTemplates = useClientSort(templates, sortBy, OUTREACH_SORT_CONFIGS);

  const fetchTemplates = useCallback(async () => {
    try {
      const res = await fetch('/api/outreach', { credentials: 'include' });
      if (res.ok) {
        const data = await res.json();
        setTemplates(data.rows || []);
      }
    } catch {
      // fetch error handled by loading state
    } finally {
      setLoading(false);
    }
  }, []);

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

  return (
    <div className="p-6 space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between flex-wrap gap-3">
        <div>
          <h2 className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>
            Outreach Tools
          </h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            AI-generated templates and tools for effective donor and partner outreach.
          </p>
        </div>
        <div className="flex items-center gap-2">
          <SortDropdown options={OUTREACH_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
          <button className="btn btn-primary" onClick={() => setShowGenerateModal('introduction')}>
            <Plus size={15} />
            New Template
          </button>
        </div>
      </div>

      {/* Template Type Grid */}
      <div>
        <h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--color-text)' }}>
          Generate from Template
        </h3>
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
          {TEMPLATE_TYPES.map(({ Icon, label, desc, type }) => (
            <button
              key={type}
              className="flex items-start gap-3 p-4 rounded-lg text-left w-full"
              style={{
                backgroundColor: 'var(--color-surface)',
                border: '1px solid var(--color-border)',
                transition: 'border-color 0.15s, background-color 0.15s',
                cursor: 'pointer',
              }}
              onClick={() => setShowGenerateModal(type)}
              onMouseEnter={(e) => {
                e.currentTarget.style.borderColor = 'rgba(5,150,105,0.4)';
              }}
              onMouseLeave={(e) => {
                e.currentTarget.style.borderColor = 'var(--color-border)';
              }}
            >
              <div
                className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
                style={{
                  backgroundColor: 'rgba(5,150,105,0.12)',
                  border: '1px solid rgba(5,150,105,0.2)',
                }}
              >
                <Icon size={16} style={{ color: '#34d399' }} />
              </div>
              <div>
                <div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
                  {label}
                </div>
                <div className="text-xs mt-0.5" style={{ color: 'var(--color-text-muted)' }}>
                  {desc}
                </div>
              </div>
            </button>
          ))}
        </div>
      </div>

      {/* Saved Templates */}
      <div>
        <div className="flex items-center gap-2 mb-3">
          <FileText size={16} style={{ color: 'var(--color-primary)' }} />
          <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
            Saved Templates ({templates.length})
          </h3>
        </div>

        {loading ? (
          <SkeletonList count={3} />
        ) : sortedTemplates.length === 0 ? (
          <div className="card">
            <div className="flex flex-col items-center justify-center py-12 text-center">
              <div
                className="w-12 h-12 rounded-xl flex items-center justify-center mb-3"
                style={{
                  backgroundColor: 'var(--color-surface-el)',
                  border: '1px solid var(--color-border)',
                }}
              >
                <Megaphone size={20} style={{ color: 'var(--color-text-muted)' }} />
              </div>
              <p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
                No saved templates yet
              </p>
              <p className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>
                Generated outreach templates will be saved here for reuse
              </p>
            </div>
          </div>
        ) : (
          <div className="space-y-3">
            {sortedTemplates.map((t) => {
              const isExpanded = expandedId === t.id;
              const badgeColor = TYPE_BADGE_COLORS[t.template_type] || '#059669';
              const plainText = t.body_text || stripHtml(t.body_html);

              return (
                <div key={t.id} className="card" style={{ padding: 0 }}>
                  <div className="p-4">
                    <div className="flex items-start justify-between gap-3">
                      <div className="flex-1 min-w-0">
                        <div className="flex items-center gap-2 flex-wrap mb-1">
                          <h4 className="text-sm font-semibold truncate" style={{ color: 'var(--color-text)' }}>
                            {t.title}
                          </h4>
                          <span
                            className="badge"
                            style={{
                              backgroundColor: `${badgeColor}20`,
                              color: badgeColor,
                              border: `1px solid ${badgeColor}40`,
                            }}
                          >
                            {TYPE_LABELS[t.template_type] || t.template_type}
                          </span>
                          {t.target_type && (
                            <span className="badge badge-info">
                              {TARGET_LABELS[t.target_type] || t.target_type}
                            </span>
                          )}
                          {t.is_ai_generated && (
                            <Sparkles size={12} style={{ color: '#34d399' }} />
                          )}
                        </div>

                        {t.subject && (
                          <p className="text-xs mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                            Subject: {t.subject}
                          </p>
                        )}

                        {!isExpanded && (
                          <p className="text-xs truncate" style={{ color: 'var(--color-text-muted)' }}>
                            {plainText.substring(0, 150)}...
                          </p>
                        )}

                        <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
                          {formatDate(t.created_at)}
                        </span>
                      </div>
                      <button
                        className="btn btn-ghost btn-sm shrink-0"
                        onClick={() => setExpandedId(isExpanded ? null : t.id)}
                        aria-label={isExpanded ? `Collapse ${t.title}` : `Expand ${t.title}`}
                        aria-expanded={isExpanded}
                      >
                        {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
                      </button>
                    </div>
                  </div>

                  {isExpanded && (
                    <div
                      className="px-4 pb-4 space-y-3"
                      style={{ borderTop: '1px solid var(--color-border)' }}
                    >
                      <div
                        className="p-4 rounded-lg mt-3"
                        style={{
                          backgroundColor: 'var(--color-surface-el)',
                          border: '1px solid var(--color-border)',
                          color: 'var(--color-text-secondary)',
                          fontSize: '0.8125rem',
                          lineHeight: '1.6',
                        }}
                      >
                        {plainText.split('\n').map((line, i) => (
                          <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
                            {line || '\u00A0'}
                          </p>
                        ))}
                      </div>
                      <div className="flex gap-2">
                        <button
                          className="btn btn-secondary btn-sm flex-1"
                          onClick={async () => {
                            try {
                              await navigator.clipboard.writeText(plainText);
                            } catch {
                              const ta = document.createElement('textarea');
                              ta.value = plainText;
                              document.body.appendChild(ta);
                              ta.select();
                              document.execCommand('copy');
                              document.body.removeChild(ta);
                            }
                          }}
                        >
                          <Copy size={14} />
                          Copy
                        </button>
                        <button
                          className="btn btn-primary btn-sm flex-1"
                          onClick={() => {
                            window.open(
                              `mailto:?subject=${encodeURIComponent(t.subject || '')}&body=${encodeURIComponent(plainText)}`,
                              '_blank',
                            );
                          }}
                        >
                          <ExternalLink size={14} />
                          Open in Email
                        </button>
                      </div>
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        )}
      </div>

      {/* Generate Modal */}
      {showGenerateModal && (
        <GenerateTemplateModal
          defaultType={showGenerateModal}
          onClose={() => setShowGenerateModal(null)}
          onCreated={fetchTemplates}
        />
      )}
    </div>
  );
}

/* ─── Generate Template Modal ────────────────────────────────────────────── */
function GenerateTemplateModal({
  defaultType,
  onClose,
  onCreated,
}: {
  defaultType: string;
  onClose: () => void;
  onCreated: () => void;
}) {
  const { addToast } = useToast();
  const { canUseAI } = useAuth();
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  // Store previously focused element and focus the modal on mount
  useEffect(() => {
    previousFocusRef.current = document.activeElement as HTMLElement;
    modalRef.current?.focus();
    return () => {
      previousFocusRef.current?.focus();
    };
  }, []);

  // Escape to close + focus trapping
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
      return;
    }
    if (e.key === 'Tab' && modalRef.current) {
      const focusable = modalRef.current.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first.focus();
        }
      }
    }
  }, [onClose]);

  const [templateType, setTemplateType] = useState(defaultType);
  const [targetType, setTargetType] = useState('general');
  const [targetName, setTargetName] = useState('');
  const [targetOrg, setTargetOrg] = useState('');
  const [context, setContext] = useState('');
  const [generating, setGenerating] = useState(false);
  const [result, setResult] = useState<{ subject: string; body_text: string } | null>(null);
  const [copied, setCopied] = useState(false);

  const handleGenerate = async () => {
    setGenerating(true);
    try {
      const res = await fetch('/api/outreach/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({
          target_name: targetName || undefined,
          target_org: targetOrg || undefined,
          target_type: targetType,
          template_type: templateType,
          context: context || undefined,
        }),
      });
      if (res.ok) {
        const data = await res.json();
        setResult({ subject: data.subject, body_text: data.body_text });
        onCreated();
        addToast('Template generated', 'success');
      } else {
        addToast('Generation failed', 'error');
      }
    } catch {
      addToast('Generation failed', 'error');
    } finally {
      setGenerating(false);
    }
  };

  const handleCopy = async () => {
    if (!result) return;
    try {
      await navigator.clipboard.writeText(result.body_text);
    } catch {
      const ta = document.createElement('textarea');
      ta.value = result.body_text;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
    addToast('Copied to clipboard', 'success');
  };

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center p-4"
      style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
      onClick={onClose}
      role="dialog"
      aria-modal="true"
      aria-labelledby="generate-template-modal-title"
      onKeyDown={handleKeyDown}
    >
      <div
        ref={modalRef}
        className="card w-full max-w-2xl max-h-[90vh] overflow-auto"
        style={{ backgroundColor: 'var(--color-surface)' }}
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <div className="flex items-center justify-between mb-4">
          <h3 id="generate-template-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
            Generate Outreach Template
          </h3>
          <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
            <X size={16} />
          </button>
        </div>

        {!result ? (
          <div className="space-y-3">
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                  Template Type
                </label>
                <select
                  className="input"
                  value={templateType}
                  onChange={(e) => setTemplateType(e.target.value)}
                >
                  {Object.entries(TYPE_LABELS).map(([val, label]) => (
                    <option key={val} value={val}>{label}</option>
                  ))}
                </select>
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                  Target Type
                </label>
                <select
                  className="input"
                  value={targetType}
                  onChange={(e) => setTargetType(e.target.value)}
                >
                  {Object.entries(TARGET_LABELS).map(([val, label]) => (
                    <option key={val} value={val}>{label}</option>
                  ))}
                </select>
              </div>
            </div>
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                  Contact Name
                </label>
                <input
                  className="input"
                  placeholder="John Smith"
                  value={targetName}
                  onChange={(e) => setTargetName(e.target.value)}
                />
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                  Organization
                </label>
                <input
                  className="input"
                  placeholder="Ford Foundation"
                  value={targetOrg}
                  onChange={(e) => setTargetOrg(e.target.value)}
                />
              </div>
            </div>
            <div>
              <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                Context (optional)
              </label>
              <textarea
                className="input"
                rows={3}
                placeholder="Any context about the relationship, recent events, specific ask..."
                value={context}
                onChange={(e) => setContext(e.target.value)}
                style={{ resize: 'vertical' }}
              />
            </div>
            {canUseAI ? (
              <button
                className="btn btn-primary w-full"
                onClick={handleGenerate}
                disabled={generating}
              >
                {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
                {generating ? 'Generating...' : 'Generate Template'}
              </button>
            ) : (
              <p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
                AI generation isn’t available for this account.
              </p>
            )}
          </div>
        ) : (
          <div className="space-y-3">
            <div
              className="p-3 rounded-lg"
              style={{ backgroundColor: 'var(--color-surface-el)', border: '1px solid var(--color-border)' }}
            >
              <h4 className="text-xs font-semibold mb-1" style={{ color: 'var(--color-text-secondary)' }}>
                Subject
              </h4>
              <p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
                {result.subject}
              </p>
            </div>
            <div
              className="p-4 rounded-lg"
              style={{
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
                color: 'var(--color-text-secondary)',
                fontSize: '0.8125rem',
                lineHeight: '1.6',
              }}
            >
              {result.body_text.split('\n').map((line, i) => (
                <p key={i} style={{ marginBottom: line.trim() ? '0.5rem' : '0.25rem' }}>
                  {line || '\u00A0'}
                </p>
              ))}
            </div>
            <div className="flex gap-2">
              <button className="btn btn-secondary flex-1" onClick={handleCopy}>
                <Copy size={14} />
                {copied ? 'Copied!' : 'Copy to Clipboard'}
              </button>
              <button
                className="btn btn-primary flex-1"
                onClick={() => {
                  window.open(
                    `mailto:?subject=${encodeURIComponent(result.subject)}&body=${encodeURIComponent(result.body_text)}`,
                    '_blank',
                  );
                }}
              >
                <ExternalLink size={14} />
                Open in Email
              </button>
            </div>
            <button className="btn btn-ghost btn-sm w-full" onClick={() => setResult(null)}>
              Generate Another
            </button>
          </div>
        )}
      </div>
    </div>
  );
}