← back to Norma

components/email-analyzer/SubjectLineMakerPanel.tsx

378 lines

'use client';

/**
 * Subject Line Maker.
 *
 * Generates N stylistic variants via Gemini, lets user favorite + note learnings,
 * persists picks to email_analyzer_subject_lines, and surfaces saved history.
 */

import { useState, useEffect, useCallback } from 'react';
import {
  Sparkles,
  Star,
  Copy,
  Trash2,
  StickyNote,
  RefreshCw,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';

interface GeneratedSubject {
  subject: string;
  style: string;
  reasoning: string;
}

interface SavedSubject {
  id: string;
  subject: string;
  style: string | null;
  reasoning: string | null;
  is_favorite: boolean;
  notes: string | null;
  created_at: string;
}

interface SubjectLineMakerPanelProps {
  sessionId: string | null;
  versionId: string | null;
  emailBody: string;
}

const STYLE_COLORS: Record<string, string> = {
  curiosity: '#6366f1',
  urgency: '#f43f5e',
  'value-forward': '#10b981',
  personal: '#0a7c59',
  'short-punchy': '#f59e0b',
  'specific-number': '#0ea5e9',
  emotional: '#d97706',
  'news-angle': '#4f46e5',
};

export default function SubjectLineMakerPanel({
  sessionId,
  versionId,
  emailBody,
}: SubjectLineMakerPanelProps) {
  const { addToast } = useToast();
  const [generated, setGenerated] = useState<GeneratedSubject[]>([]);
  const [generating, setGenerating] = useState(false);
  const [saved, setSaved] = useState<SavedSubject[]>([]);
  const [notesOpenId, setNotesOpenId] = useState<string | null>(null);
  const [notesDraft, setNotesDraft] = useState('');

  const loadSaved = useCallback(async () => {
    if (!sessionId) return;
    try {
      const res = await fetch(`/api/email-analyzer/subject-lines?session_id=${sessionId}`);
      if (!res.ok) return;
      const data = await res.json();
      setSaved(data.subjects || []);
    } catch { /* silent */ }
  }, [sessionId]);

  useEffect(() => { void loadSaved(); }, [loadSaved]);

  async function handleGenerate() {
    if (!emailBody.trim()) {
      addToast('Paste an email first so we know what to make subjects for', 'error');
      return;
    }
    setGenerating(true);
    try {
      const res = await fetch('/api/email-analyzer/subject-lines/generate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ emailBody, count: 8 }),
      });
      if (!res.ok) throw new Error('generation failed');
      const data = await res.json();
      setGenerated(data.subjects || []);
      if (!data.subjects?.length) {
        addToast('No subjects returned — try again', 'error');
      }
    } catch {
      addToast('Subject generation failed', 'error');
    } finally {
      setGenerating(false);
    }
  }

  async function handleSave(gs: GeneratedSubject) {
    if (!sessionId) {
      addToast('No session — cannot save', 'error');
      return;
    }
    try {
      const res = await fetch('/api/email-analyzer/subject-lines', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          session_id: sessionId,
          version_id: versionId,
          subject: gs.subject,
          style: gs.style,
          reasoning: gs.reasoning,
        }),
      });
      if (!res.ok) throw new Error('save failed');
      addToast('Subject saved', 'success');
      await loadSaved();
    } catch {
      addToast('Failed to save subject', 'error');
    }
  }

  async function toggleFavorite(id: string, next: boolean) {
    try {
      const res = await fetch('/api/email-analyzer/subject-lines', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id, is_favorite: next }),
      });
      if (!res.ok) throw new Error();
      setSaved((prev) => prev.map((s) => (s.id === id ? { ...s, is_favorite: next } : s)));
    } catch {
      addToast('Failed to toggle favorite', 'error');
    }
  }

  async function saveNotes(id: string) {
    try {
      const res = await fetch('/api/email-analyzer/subject-lines', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id, notes: notesDraft }),
      });
      if (!res.ok) throw new Error();
      setSaved((prev) => prev.map((s) => (s.id === id ? { ...s, notes: notesDraft } : s)));

      // Also log as a learning for cross-session reuse.
      const subjectRow = saved.find((s) => s.id === id);
      if (subjectRow && notesDraft.trim()) {
        await fetch('/api/email-analyzer/learnings', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            session_id: sessionId,
            version_id: versionId,
            element_type: 'subject',
            element_id: id,
            snippet: subjectRow.subject,
            learning: notesDraft,
            tags: subjectRow.style ? [subjectRow.style] : [],
          }),
        });
      }

      addToast('Notes saved', 'success');
      setNotesOpenId(null);
      setNotesDraft('');
    } catch {
      addToast('Failed to save notes', 'error');
    }
  }

  async function handleDelete(id: string) {
    try {
      const res = await fetch(`/api/email-analyzer/subject-lines?id=${id}`, {
        method: 'DELETE',
      });
      if (!res.ok) throw new Error();
      setSaved((prev) => prev.filter((s) => s.id !== id));
    } catch {
      addToast('Failed to delete', 'error');
    }
  }

  function copySubject(s: string) {
    navigator.clipboard.writeText(s).then(
      () => addToast('Subject copied', 'success'),
      () => addToast('Copy failed', 'error'),
    );
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
      {/* Generate control */}
      <button
        type="button"
        className="btn btn-primary btn-sm"
        style={{ gap: 6, alignSelf: 'flex-start' }}
        onClick={() => void handleGenerate()}
        disabled={generating || !emailBody.trim()}
      >
        {generating ? <RefreshCw size={13} className="animate-spin" /> : <Sparkles size={13} />}
        {generating ? 'Generating...' : 'Generate 8 subject options'}
      </button>

      {/* Generated options */}
      {generated.length > 0 && (
        <div>
          <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
            Generated
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {generated.map((g, i) => {
              const color = STYLE_COLORS[g.style] || 'var(--color-text-muted)';
              return (
                <div
                  key={`gen-${i}`}
                  style={{
                    padding: 8,
                    border: '1px solid var(--color-border)',
                    borderRadius: 8,
                    background: 'var(--color-surface)',
                  }}
                >
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
                    <span
                      className="badge"
                      style={{
                        fontSize: 10,
                        padding: '1px 6px',
                        background: `${color}12`,
                        color,
                        border: `1px solid ${color}30`,
                        textTransform: 'capitalize',
                      }}
                    >
                      {g.style}
                    </span>
                    <span style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>
                      {g.subject.length} chars
                    </span>
                  </div>
                  <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', marginBottom: 4 }}>
                    {g.subject}
                  </div>
                  {g.reasoning && (
                    <div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 6, fontStyle: 'italic' }}>
                      {g.reasoning}
                    </div>
                  )}
                  <div style={{ display: 'flex', gap: 4 }}>
                    <button type="button" className="btn btn-ghost btn-sm" style={{ fontSize: 11, padding: '2px 8px', minHeight: 24, gap: 3 }} onClick={() => copySubject(g.subject)}>
                      <Copy size={10} /> Copy
                    </button>
                    <button type="button" className="btn btn-ghost btn-sm" style={{ fontSize: 11, padding: '2px 8px', minHeight: 24, gap: 3, color: 'var(--color-primary)' }} onClick={() => void handleSave(g)}>
                      <Star size={10} /> Save
                    </button>
                  </div>
                </div>
              );
            })}
          </div>
        </div>
      )}

      {/* Saved history */}
      {saved.length > 0 && (
        <div>
          <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
            Saved ({saved.length})
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
            {saved.map((s) => {
              const color = s.style ? (STYLE_COLORS[s.style] || 'var(--color-text-muted)') : 'var(--color-text-muted)';
              const notesOpen = notesOpenId === s.id;
              return (
                <div
                  key={s.id}
                  style={{
                    padding: 8,
                    border: s.is_favorite ? '1px solid #f59e0b' : '1px solid var(--color-border)',
                    borderRadius: 8,
                    background: s.is_favorite ? 'rgba(245,158,11,0.04)' : 'var(--color-surface)',
                  }}
                >
                  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                    <button
                      type="button"
                      onClick={() => void toggleFavorite(s.id, !s.is_favorite)}
                      style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: s.is_favorite ? '#f59e0b' : 'var(--color-text-muted)' }}
                      title={s.is_favorite ? 'Remove from favorites' : 'Favorite'}
                    >
                      <Star size={14} fill={s.is_favorite ? '#f59e0b' : 'none'} />
                    </button>
                    <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={s.subject}>
                      {s.subject}
                    </span>
                    {s.style && (
                      <span className="badge" style={{ fontSize: 10, padding: '1px 6px', background: `${color}12`, color, border: `1px solid ${color}30`, textTransform: 'capitalize' }}>
                        {s.style}
                      </span>
                    )}
                  </div>
                  <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
                    <button type="button" className="btn btn-ghost btn-sm" style={{ fontSize: 11, padding: '2px 8px', minHeight: 24, gap: 3 }} onClick={() => copySubject(s.subject)}>
                      <Copy size={10} /> Copy
                    </button>
                    <button
                      type="button"
                      className="btn btn-ghost btn-sm"
                      style={{ fontSize: 11, padding: '2px 8px', minHeight: 24, gap: 3, color: 'var(--color-lane-b)' }}
                      onClick={() => {
                        if (notesOpen) {
                          setNotesOpenId(null);
                          setNotesDraft('');
                        } else {
                          setNotesOpenId(s.id);
                          setNotesDraft(s.notes || '');
                        }
                      }}
                    >
                      <StickyNote size={10} /> {s.notes ? 'Notes ✓' : 'Add note'}
                    </button>
                    <button
                      type="button"
                      className="btn btn-ghost btn-sm"
                      style={{ fontSize: 11, padding: '2px 8px', minHeight: 24, marginLeft: 'auto', color: '#f43f5e' }}
                      onClick={() => void handleDelete(s.id)}
                    >
                      <Trash2 size={10} />
                    </button>
                  </div>
                  {notesOpen && (
                    <div style={{ marginTop: 6 }}>
                      <textarea
                        className="input"
                        rows={2}
                        style={{ width: '100%', fontSize: 12 }}
                        placeholder="What did you learn from this subject? (gets logged as a reusable learning)"
                        value={notesDraft}
                        onChange={(e) => setNotesDraft(e.target.value)}
                      />
                      <div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
                        <button type="button" className="btn btn-primary btn-sm" style={{ fontSize: 11, padding: '2px 10px', minHeight: 24 }} onClick={() => void saveNotes(s.id)}>
                          Save learning
                        </button>
                        <button type="button" className="btn btn-ghost btn-sm" style={{ fontSize: 11, padding: '2px 10px', minHeight: 24 }} onClick={() => { setNotesOpenId(null); setNotesDraft(''); }}>
                          Cancel
                        </button>
                      </div>
                    </div>
                  )}
                  {!notesOpen && s.notes && (
                    <div style={{ fontSize: 11, color: 'var(--color-text-secondary)', fontStyle: 'italic', marginTop: 4, paddingLeft: 8, borderLeft: '2px solid var(--color-border)' }}>
                      {s.notes}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}

      {generated.length === 0 && saved.length === 0 && !generating && (
        <div style={{ padding: '20px 10px', textAlign: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
          Click generate to get 8 subject-line variants in different persuasion styles.
        </div>
      )}
    </div>
  );
}