← back to Patty

components/subscribers/SubscribersTab.tsx

447 lines

'use client';

import { useState, useEffect, useCallback } from 'react';
import { SkeletonList } from '../Skeleton';
import {
  Users, UserPlus, Search, Loader2, X, Copy,
  Tag, ToggleLeft, ToggleRight, UserMinus,
} from 'lucide-react';
import { useToast } from '../ToastProvider';
import { useDebounce } from '@/hooks/useDebounce';
import SortDropdown from '../shared/SortDropdown';
import { useClientSort, SortConfig } from '@/hooks/useClientSort';

const SUB_SORT_OPTIONS = [
  { value: 'email_asc', label: 'Email A-Z' },
  { value: 'date_desc', label: 'Newest First' },
  { value: 'date_asc', label: 'Oldest First' },
  { value: 'active_desc', label: 'Active First' },
];

const SUB_SORT_CONFIGS: Record<string, SortConfig> = {
  email_asc: { key: 'email', direction: 'asc', type: 'string' },
  date_desc: { key: 'created_at', direction: 'desc', type: 'date' },
  date_asc: { key: 'created_at', direction: 'asc', type: 'date' },
  active_desc: { key: 'is_active', direction: 'desc', type: 'string' },
};

/* ─── Types ─────────────────────────────────────────────────── */
interface Subscriber {
  id: string;
  email: string;
  name: string | null;
  zip_code: string | null;
  source: string;
  petition_ids: string[] | null;
  tags: string[] | null;
  is_active: boolean;
  unsubscribed_at: string | null;
  created_at: string;
}

interface Stats {
  total: number;
  active: number;
  inactive: number;
  by_source: Record<string, number>;
}

/* ─── Component ─────────────────────────────────────────────── */
export default function SubscribersTab() {
  const { addToast } = useToast();
  const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
  const [loading, setLoading] = useState(true);
  const [stats, setStats] = useState<Stats>({ total: 0, active: 0, inactive: 0, by_source: {} });
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 300);
  const [sourceFilter, setSourceFilter] = useState('all');
  const [sortBy, setSortBy] = useState('date_desc');
  const sortedSubscribers = useClientSort(subscribers, sortBy, SUB_SORT_CONFIGS);

  // Add modal
  const [showAdd, setShowAdd] = useState(false);
  const [addEmail, setAddEmail] = useState('');
  const [addName, setAddName] = useState('');
  const [addZip, setAddZip] = useState('');
  const [addSaving, setAddSaving] = useState(false);

  // Selected for bulk
  const [selected, setSelected] = useState<Set<string>>(new Set());

  /* ── Fetch ──────────────────────────────────────────────────── */
  const fetchSubscribers = useCallback(async () => {
    setLoading(true);
    try {
      const params = new URLSearchParams();
      if (debouncedSearch) params.set('search', debouncedSearch);
      if (sourceFilter !== 'all') params.set('source', sourceFilter);
      const res = await fetch(`/api/subscribers?${params.toString()}`);
      if (res.ok) {
        const data = await res.json();
        setSubscribers(data.rows || []);
        setStats(data.stats || { total: 0, active: 0, inactive: 0, by_source: {} });
      }
    } catch (err) {
      console.error('Fetch subscribers error:', err);
    } finally {
      setLoading(false);
    }
  }, [debouncedSearch, sourceFilter]);

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

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

  /* ── Add Subscriber ─────────────────────────────────────────── */
  async function handleAdd() {
    if (!addEmail.trim()) return;
    setAddSaving(true);
    try {
      const res = await fetch('/api/subscribers', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email: addEmail, name: addName || null, zip_code: addZip || null, source: 'manual' }),
      });
      if (res.ok) {
        setShowAdd(false);
        setAddEmail('');
        setAddName('');
        setAddZip('');
        fetchSubscribers();
        addToast('Subscriber added', 'success');
      } else {
        addToast('Failed to add subscriber', 'error');
      }
    } catch (err) {
      console.error('Add subscriber error:', err);
      addToast('Failed to add subscriber', 'error');
    } finally {
      setAddSaving(false);
    }
  }

  /* ── Toggle Active ──────────────────────────────────────────── */
  async function toggleActive(sub: Subscriber) {
    try {
      if (sub.is_active) {
        // Unsubscribe
        await fetch(`/api/subscribers?id=${sub.id}`, { method: 'DELETE' });
      } else {
        // Reactivate
        await fetch('/api/subscribers', {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: sub.id, is_active: true }),
        });
      }
      fetchSubscribers();
      addToast(sub.is_active ? 'Subscriber deactivated' : 'Subscriber reactivated', 'info');
    } catch (err) {
      console.error('Toggle active error:', err);
      addToast('Toggle failed', 'error');
    }
  }

  /* ── Unsubscribe ────────────────────────────────────────────── */
  async function handleUnsubscribe(id: string) {
    if (!confirm('Unsubscribe this subscriber?')) return;
    try {
      await fetch(`/api/subscribers?id=${id}`, { method: 'DELETE' });
      fetchSubscribers();
      addToast('Subscriber unsubscribed', 'info');
    } catch (err) {
      console.error('Unsubscribe error:', err);
      addToast('Unsubscribe failed', 'error');
    }
  }

  /* ── Bulk Copy Emails ───────────────────────────────────────── */
  function handleCopyEmails() {
    const emails = subscribers
      .filter((s) => selected.has(s.id))
      .map((s) => s.email)
      .join(', ');
    navigator.clipboard.writeText(emails);
    addToast(`${selected.size} email(s) copied`, 'success');
  }

  /* ── Selection ──────────────────────────────────────────────── */
  function toggleSelected(id: string) {
    const next = new Set(selected);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    setSelected(next);
  }

  function toggleAll() {
    if (selected.size === subscribers.length) {
      setSelected(new Set());
    } else {
      setSelected(new Set(subscribers.map((s) => s.id)));
    }
  }

  /* ── Helpers ────────────────────────────────────────────────── */
  const sourceLabels: string[] = ['all', ...Object.keys(stats.by_source)];

  const sourceBadgeColor: Record<string, string> = {
    manual: '#a78bfa',
    petition: '#7c3aed',
    signup: '#22c55e',
    import: '#3b82f6',
  };

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

  /* ─── 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)' }}>Subscribers</h2>
          <p className="text-sm mt-1" style={{ color: 'var(--color-text-muted)' }}>
            Manage your email list across all petitions
          </p>
        </div>
        <div className="flex items-center gap-2">
          {selected.size > 0 && (
            <button className="btn btn-secondary btn-sm" onClick={handleCopyEmails} title="Copy selected emails">
              <Copy size={14} /> Copy {selected.size} Emails
            </button>
          )}
          <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
            <UserPlus size={16} />
            Add Subscriber
          </button>
        </div>
      </div>

      {/* Stats Row */}
      <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
        <div className="card">
          <div className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>{stats.total}</div>
          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Total Subscribers</div>
        </div>
        <div className="card">
          <div className="text-2xl font-bold" style={{ color: 'var(--color-success)' }}>{stats.active}</div>
          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Active</div>
        </div>
        <div className="card">
          <div className="text-2xl font-bold" style={{ color: 'var(--color-error)' }}>{stats.inactive}</div>
          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>Inactive</div>
        </div>
        <div className="card">
          <div className="flex items-center gap-2 flex-wrap">
            {Object.entries(stats.by_source).map(([src, cnt]) => (
              <span key={src} className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
                {src}: <strong>{cnt}</strong>
              </span>
            ))}
            {Object.keys(stats.by_source).length === 0 && (
              <span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>No sources</span>
            )}
          </div>
          <div className="text-xs mt-1" style={{ color: 'var(--color-text-muted)' }}>By Source</div>
        </div>
      </div>

      {/* Sort + Search + Filters */}
      <div className="flex items-center gap-3 mb-6 flex-wrap">
        <SortDropdown options={SUB_SORT_OPTIONS} value={sortBy} onChange={setSortBy} />
        <div className="flex-1 relative" style={{ minWidth: 200 }}>
          <Search
            size={16}
            style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
          />
          <input
            className="input"
            placeholder="Search by email or name..."
            style={{ paddingLeft: 36 }}
            value={search}
            onChange={(e) => setSearch(e.target.value)}
          />
        </div>
        <div className="flex items-center gap-1 flex-wrap">
          {sourceLabels.map((s) => (
            <button
              key={s}
              onClick={() => setSourceFilter(s)}
              className={`btn btn-sm ${sourceFilter === s ? 'btn-primary' : 'btn-ghost'}`}
              style={{ textTransform: 'capitalize' }}
            >
              {s === 'all' ? 'All Sources' : s}
            </button>
          ))}
        </div>
      </div>

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

      {/* Empty State */}
      {!loading && subscribers.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)',
            }}
          >
            <Users size={28} style={{ color: 'var(--color-primary)' }} />
          </div>
          <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--color-text)' }}>No subscribers yet</h3>
          <p className="text-sm mb-6 max-w-md" style={{ color: 'var(--color-text-muted)' }}>
            Subscribers are added when people sign petitions or you add them manually.
          </p>
          <button className="btn btn-primary" onClick={() => setShowAdd(true)}>
            <UserPlus size={16} /> Add Manually
          </button>
        </div>
      )}

      {/* Subscriber Table */}
      {!loading && subscribers.length > 0 && (
        <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
            <thead>
              <tr style={{ borderBottom: '1px solid var(--color-border)' }}>
                <th style={{ padding: '10px 12px', textAlign: 'left' }}>
                  <input
                    type="checkbox"
                    checked={selected.size === subscribers.length && subscribers.length > 0}
                    onChange={toggleAll}
                    style={{ accentColor: 'var(--color-primary)' }}
                  />
                </th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Email</th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Name</th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Zip</th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Source</th>
                <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Active</th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Petitions</th>
                <th style={{ padding: '10px 12px', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Joined</th>
                <th style={{ padding: '10px 12px', textAlign: 'center', color: 'var(--color-text-muted)', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.04em' }}>Actions</th>
              </tr>
            </thead>
            <tbody>
              {sortedSubscribers.map((s) => {
                const srcColor = sourceBadgeColor[s.source] || 'var(--color-text-muted)';
                return (
                  <tr
                    key={s.id}
                    style={{
                      borderBottom: '1px solid var(--color-border)',
                      backgroundColor: selected.has(s.id) ? 'rgba(124, 58, 237, 0.05)' : 'transparent',
                    }}
                  >
                    <td style={{ padding: '8px 12px' }}>
                      <input
                        type="checkbox"
                        checked={selected.has(s.id)}
                        onChange={() => toggleSelected(s.id)}
                        style={{ accentColor: 'var(--color-primary)' }}
                      />
                    </td>
                    <td style={{ padding: '8px 12px', color: 'var(--color-text)' }}>{s.email}</td>
                    <td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{s.name || '--'}</td>
                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>{s.zip_code || '--'}</td>
                    <td style={{ padding: '8px 12px' }}>
                      <span
                        className="badge"
                        style={{
                          backgroundColor: `${srcColor}20`,
                          color: srcColor,
                          border: `1px solid ${srcColor}40`,
                        }}
                      >
                        {s.source}
                      </span>
                    </td>
                    <td style={{ padding: '8px 12px', textAlign: 'center' }}>
                      <button
                        className="btn btn-ghost btn-sm"
                        onClick={() => toggleActive(s)}
                        style={{ color: s.is_active ? 'var(--color-success)' : 'var(--color-error)' }}
                        title={s.is_active ? 'Active - click to deactivate' : 'Inactive - click to reactivate'}
                      >
                        {s.is_active ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
                      </button>
                    </td>
                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)' }}>
                      {s.petition_ids?.length || 0}
                    </td>
                    <td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', fontSize: 12 }}>
                      {formatDate(s.created_at)}
                    </td>
                    <td style={{ padding: '8px 12px', textAlign: 'center' }}>
                      <button
                        className="btn btn-ghost btn-sm"
                        onClick={() => handleUnsubscribe(s.id)}
                        style={{ color: 'var(--color-error)' }}
                        title="Unsubscribe"
                      >
                        <UserMinus size={14} />
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      )}

      {/* ─── Add Subscriber Modal ─────────────────────────────────── */}
      {showAdd && (
        <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) setShowAdd(false); }}
        >
          <div style={{
            width: '100%', maxWidth: 420, 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)' }}>Add Subscriber</h3>
              <button className="btn btn-ghost btn-sm" onClick={() => setShowAdd(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)' }}>Email *</label>
                <input className="input" type="email" value={addEmail} onChange={(e) => setAddEmail(e.target.value)} placeholder="email@example.com" />
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Name</label>
                <input className="input" value={addName} onChange={(e) => setAddName(e.target.value)} placeholder="Full name" />
              </div>
              <div>
                <label className="block text-xs font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>Zip Code</label>
                <input className="input" value={addZip} onChange={(e) => setAddZip(e.target.value)} placeholder="12345" />
              </div>
              <button className="btn btn-primary w-full" onClick={handleAdd} disabled={addSaving || !addEmail.trim()}>
                {addSaving ? <><Loader2 size={16} className="animate-spin" /> Adding...</> : <><UserPlus size={16} /> Add Subscriber</>}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}