← back to Patty

components/campaigns/CampaignsTab.tsx

483 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
  Mail, Plus, Send, Clock, CheckCircle, Loader2,
  X, Sparkles, Eye, MousePointer, AlertCircle,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

const CAMP_SORT_OPTIONS = [
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'status', label: 'Status A-Z' },
  { value: 'opens_desc', label: 'Most Opens' },
];

const CAMP_SORT_CONFIGS: Record<string, SortConfig> = {
  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
  status: { key: 'status', direction: 'asc', type: 'string' },
  opens_desc: { key: 'open_count', direction: 'desc', type: 'number' },
};

/* ─── Types ─────────────────────────────────────────────────── */
interface Campaign {
  id: string;
  subject: string;
  body_html: string;
  body_text: string | null;
  status: string;
  petition_id: string | null;
  petition_title: string | null;
  send_to: string;
  recipient_count: number;
  open_count: number;
  click_count: number;
  scheduled_at: string | null;
  sent_at: string | null;
  created_at: string;
}

interface PetitionOption {
  id: string;
  title: string;
}

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

/* ─── Component ─────────────────────────────────────────────── */
export default function CampaignsTab() {
  const { addToast } = useToast();
  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
  const [loading, setLoading] = useState(true);
  const [statusFilter, setStatusFilter] = useState('all');
  const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
  const [sortBy, setSortBy] = useState('date_desc');
  const sortedCampaigns = useClientSort(campaigns, sortBy, CAMP_SORT_CONFIGS);

  // Create modal state
  const [showCreate, setShowCreate] = useState(false);
  const [petitions, setPetitions] = useState<PetitionOption[]>([]);
  const [createPetitionId, setCreatePetitionId] = useState('');
  const [createSubject, setCreateSubject] = useState('');
  const [createBody, setCreateBody] = useState('');
  const [createSchedule, setCreateSchedule] = useState('');
  const [createSaving, setCreateSaving] = useState(false);
  const [aiGenerating, setAiGenerating] = useState(false);

  // Preview modal
  const [previewCampaign, setPreviewCampaign] = useState<Campaign | null>(null);

  /* ── Fetch Campaigns ────────────────────────────────────────── */
  const fetchCampaigns = useCallback(async () => {
    setLoading(true);
    try {
      const params = new URLSearchParams();
      if (statusFilter !== 'all') params.set('status', statusFilter);
      const res = await fetch(`/api/campaigns?${params.toString()}`);
      if (res.ok) {
        const data = await res.json();
        setCampaigns(data.rows || []);
        setStatusCounts(data.statusCounts || {});
      }
    } catch (err) {
      console.error('Fetch campaigns error:', err);
    } finally {
      setLoading(false);
    }
  }, [statusFilter]);

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

  /* ── Escape key handler ───────────────────────────────────────── */
  useEffect(() => {
    if (!showCreate && !previewCampaign) return;
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        if (showCreate) setShowCreate(false);
        if (previewCampaign) setPreviewCampaign(null);
      }
    };
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, [showCreate, previewCampaign]);

  /* ── Fetch Petitions for Dropdown ───────────────────────────── */
  async function fetchPetitions() {
    try {
      const res = await fetch('/api/petitions');
      if (res.ok) {
        const data = await res.json();
        setPetitions((data.rows || []).map((r: { id: string; title: string }) => ({ id: r.id, title: r.title })));
      }
    } catch (err) {
      console.error('Fetch petitions error:', err);
    }
  }

  function openCreate() {
    setShowCreate(true);
    setCreatePetitionId('');
    setCreateSubject('');
    setCreateBody('');
    setCreateSchedule('');
    fetchPetitions();
  }

  /* ── AI Generate ────────────────────────────────────────────── */
  async function handleAiGenerate() {
    setAiGenerating(true);
    try {
      const res = await fetch('/api/campaigns/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          petition_id: createPetitionId || null,
          tone: 'urgent',
          call_to_action_type: 'sign',
        }),
      });
      if (res.ok) {
        const data = await res.json();
        setCreateSubject(data.subject || '');
        setCreateBody(data.body_html || data.body_text || '');
        addToast('Content generated', 'success');
      } else {
        addToast('AI generation failed', 'error');
      }
    } catch (err) {
      console.error('AI generate error:', err);
      addToast('AI generation failed', 'error');
    } finally {
      setAiGenerating(false);
    }
  }

  /* ── Create Campaign ────────────────────────────────────────── */
  async function handleCreate() {
    if (!createSubject.trim() || !createBody.trim()) return;
    setCreateSaving(true);
    try {
      const res = await fetch('/api/campaigns', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          subject: createSubject,
          body_html: createBody,
          petition_id: createPetitionId || null,
          scheduled_at: createSchedule || null,
        }),
      });
      if (res.ok) {
        setShowCreate(false);
        fetchCampaigns();
        addToast('Campaign created', 'success');
      } else {
        addToast('Failed to create campaign', 'error');
      }
    } catch (err) {
      console.error('Create campaign error:', err);
      addToast('Failed to create campaign', 'error');
    } finally {
      setCreateSaving(false);
    }
  }

  /* ── Send Now ───────────────────────────────────────────────── */
  async function handleSendNow(id: string) {
    try {
      const res = await fetch('/api/campaigns', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id, status: 'sending', sent_at: new Date().toISOString() }),
      });
      if (res.ok) {
        fetchCampaigns();
        addToast('Campaign sending', 'success');
      } else {
        addToast('Send failed', 'error');
      }
    } catch (err) {
      console.error('Send now error:', err);
      addToast('Send failed', 'error');
    }
  }

  /* ── Helpers ────────────────────────────────────────────────── */
  const statusFilters = ['all', 'draft', 'scheduled', 'sending', 'sent', 'failed'];

  const statusBadge: Record<string, { cls: string; icon: React.ElementType }> = {
    draft: { cls: 'badge-draft', icon: Mail },
    scheduled: { cls: 'badge-paused', icon: Clock },
    sending: { cls: 'badge-warning', icon: Send },
    sent: { cls: 'badge-active', icon: CheckCircle },
    failed: { cls: 'badge-closed', icon: AlertCircle },
  };

  const formatDate = (d: string | null) => {
    if (!d) return '--';
    return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
  };

  const openRate = (c: Campaign) => {
    if (c.recipient_count === 0) return '0%';
    return `${Math.round((c.open_count / c.recipient_count) * 100)}%`;
  };

  const clickRate = (c: Campaign) => {
    if (c.recipient_count === 0) return '0%';
    return `${Math.round((c.click_count / c.recipient_count) * 100)}%`;
  };

  /* ── Stats ──────────────────────────────────────────────────── */
  const totalCampaigns = statusCounts.all || 0;
  const sentCount = statusCounts.sent || 0;
  const scheduledCount = statusCounts.scheduled || 0;
  const avgOpen = campaigns.length > 0
    ? Math.round(campaigns.reduce((sum, c) => sum + (c.recipient_count > 0 ? (c.open_count / c.recipient_count) * 100 : 0), 0) / campaigns.length)
    : 0;

  /* ─── Render ────────────────────────────────────────────────── */
  return (
    <div style={{ padding: '24px' }}>
      {/* Header */}
      <div className="flex items-center justify-between mb-6">
        <div>
          <h2 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>Email Campaigns</h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            Send email blasts to your petition signers and subscribers
          </p>
        </div>
        <button className="btn btn-primary" onClick={openCreate}>
          <Plus size={16} />
          Create Campaign
        </button>
      </div>

      {/* Stats Row */}
      <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
        {[
          { label: 'Total Campaigns', value: String(totalCampaigns), icon: Mail, color: 'var(--color-primary)' },
          { label: 'Sent', value: String(sentCount), icon: Send, color: 'var(--color-success)' },
          { label: 'Scheduled', value: String(scheduledCount), icon: Clock, color: 'var(--color-warning)' },
          { label: 'Avg Open Rate', value: `${avgOpen}%`, icon: CheckCircle, color: 'var(--color-secondary)' },
        ].map((stat, i) => (
          <div key={i} className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
            <div
              className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0"
              style={{ backgroundColor: `${stat.color}15`, border: `1px solid ${stat.color}30` }}
            >
              <stat.icon size={18} style={{ color: stat.color }} />
            </div>
            <div>
              <div className="text-lg font-bold" style={{ color: 'var(--color-text)' }}>{stat.value}</div>
              <div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>{stat.label}</div>
            </div>
          </div>
        ))}
      </div>

      {/* Sort + Status Filters */}
      <div className="flex items-center gap-1 mb-6 flex-wrap">
        <SortDropdown options={CAMP_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
        {statusFilters.map((s) => (
          <button
            key={s}
            onClick={() => setStatusFilter(s)}
            className={`btn btn-sm ${statusFilter === s ? 'btn-primary' : 'btn-ghost'}`}
            style={{ textTransform: 'capitalize' }}
          >
            {s}{statusCounts[s] !== undefined ? ` (${statusCounts[s]})` : ''}
          </button>
        ))}
      </div>

      {/* Loading */}
      {loading && (
        <SkeletonList count={4} />
      )}

      {/* Empty State */}
      {!loading && campaigns.length === 0 && (
        <div className="card flex flex-col items-center justify-center py-16" style={{ textAlign: 'center' }}>
          <div
            className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
            style={{
              background: 'linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(167, 139, 250, 0.1))',
              border: '1px solid rgba(124, 58, 237, 0.2)',
            }}
          >
            <Mail size={28} style={{ color: 'var(--color-primary)' }} />
          </div>
          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No campaigns yet</h3>
          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
            Create email campaigns to update petition signers and rally supporters.
          </p>
          <button className="btn btn-primary" onClick={openCreate}>
            <Plus size={16} />
            Create First Campaign
          </button>
        </div>
      )}

      {/* Campaign Cards */}
      {!loading && sortedCampaigns.length > 0 && (
        <div className="flex flex-col gap-3">
          {sortedCampaigns.map((c) => {
            const badge = statusBadge[c.status] || statusBadge.draft;
            return (
              <div key={c.id} className="card">
                <div className="flex items-start justify-between gap-4">
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center gap-2 mb-1 flex-wrap">
                      <h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
                        {c.subject}
                      </h3>
                      <span className={`badge ${badge.cls}`}>{c.status}</span>
                    </div>
                    {c.petition_title && (
                      <p className="text-xs mb-2" style={{ color: 'var(--color-secondary)' }}>
                        Linked: {c.petition_title}
                      </p>
                    )}
                    <div className="flex items-center gap-4 text-xs" style={{ color: 'var(--color-text-muted)' }}>
                      <span className="flex items-center gap-1">
                        <Mail size={12} /> {c.recipient_count} recipients
                      </span>
                      <span className="flex items-center gap-1">
                        <Eye size={12} /> {openRate(c)} opens
                      </span>
                      <span className="flex items-center gap-1">
                        <MousePointer size={12} /> {clickRate(c)} clicks
                      </span>
                      <span>
                        {c.sent_at ? `Sent ${formatDate(c.sent_at)}` : c.scheduled_at ? `Scheduled ${formatDate(c.scheduled_at)}` : `Created ${formatDate(c.created_at)}`}
                      </span>
                    </div>
                  </div>
                  <div className="flex items-center gap-1 shrink-0">
                    <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(c)} title="Preview">
                      <Eye size={14} />
                    </button>
                    {c.status === 'draft' && (
                      <button className="btn btn-sm btn-primary" onClick={() => handleSendNow(c.id)}>
                        <Send size={14} /> Send Now
                      </button>
                    )}
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      )}

      {/* ─── Create Campaign Modal ────────────────────────────────── */}
      {showCreate && (
        <div
          style={{
            position: 'fixed', inset: 0, zIndex: 50,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
          }}
          onClick={(e) => { if (e.target === e.currentTarget) setShowCreate(false); }}
        >
          <div style={{
            width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
            borderRadius: 12, padding: 24,
          }}>
            <div className="flex items-center justify-between mb-4">
              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Create Campaign</h3>
              <button className="btn btn-ghost btn-sm" onClick={() => setShowCreate(false)}><X size={16} /></button>
            </div>
            <div className="flex flex-col gap-4">
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Linked Petition (optional)</label>
                <select className="input" value={createPetitionId} onChange={(e) => setCreatePetitionId(e.target.value)}>
                  <option value="">-- None --</option>
                  {petitions.map((p) => (
                    <option key={p.id} value={p.id}>{p.title}</option>
                  ))}
                </select>
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Subject *</label>
                <input className="input" value={createSubject} onChange={(e) => setCreateSubject(e.target.value)} placeholder="Email subject line" />
              </div>
              <div>
                <div className="flex items-center justify-between mb-1">
                  <label className="block text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>Body *</label>
                  <button className="btn btn-ghost btn-sm" onClick={handleAiGenerate} disabled={aiGenerating}>
                    {aiGenerating ? <><Loader2 size={12} className="animate-spin" /> Generating...</> : <><Sparkles size={12} /> AI Generate</>}
                  </button>
                </div>
                <textarea
                  className="input"
                  rows={8}
                  value={createBody}
                  onChange={(e) => setCreateBody(e.target.value)}
                  placeholder="Email body content (HTML supported)"
                  style={{ resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
                />
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Schedule (optional)</label>
                <input className="input" type="datetime-local" value={createSchedule} onChange={(e) => setCreateSchedule(e.target.value)} />
              </div>
              <button className="btn btn-primary w-full" onClick={handleCreate} disabled={createSaving || !createSubject.trim() || !createBody.trim()}>
                {createSaving ? <><Loader2 size={16} className="animate-spin" /> Creating...</> : <><Plus size={16} /> Create Campaign</>}
              </button>
            </div>
          </div>
        </div>
      )}

      {/* ─── Preview Modal ────────────────────────────────────────── */}
      {previewCampaign && (
        <div
          style={{
            position: 'fixed', inset: 0, zIndex: 50,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            backgroundColor: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
          }}
          onClick={(e) => { if (e.target === e.currentTarget) setPreviewCampaign(null); }}
        >
          <div style={{
            width: '100%', maxWidth: 600, maxHeight: '90vh', overflow: 'auto',
            backgroundColor: 'var(--color-surface)', border: '1px solid var(--color-border)',
            borderRadius: 12, padding: 24,
          }}>
            <div className="flex items-center justify-between mb-4">
              <h3 className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>Campaign Preview</h3>
              <button className="btn btn-ghost btn-sm" onClick={() => setPreviewCampaign(null)}><X size={16} /></button>
            </div>
            <div className="mb-3">
              <div className="text-xs font-medium" style={{ color: 'var(--color-text-muted)' }}>Subject</div>
              <div className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>{previewCampaign.subject}</div>
            </div>
            <div
              className="text-sm"
              style={{
                color: 'var(--color-text-secondary)',
                padding: 12, borderRadius: 8,
                backgroundColor: 'var(--color-surface-el)',
                whiteSpace: 'pre-wrap',
                maxHeight: 400, overflow: 'auto',
              }}
            >
              {stripHtml(previewCampaign.body_html)}
            </div>
          </div>
        </div>
      )}
    </div>
  );
}