← 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>
);
}