← back to Norma
components/drafts/DraftEditor.tsx
309 lines
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { X, ChevronDown } from 'lucide-react';
import { useOrg } from '@/components/OrgProvider';
import type { LaneKey } from '@/lib/brand-types';
import EditorToggle, { type EditorMode } from '@/components/editor/EditorToggle';
import RichTextEditor from '@/components/editor/RichTextEditor';
import HtmlSourceEditor from '@/components/editor/HtmlSourceEditor';
import DraftToolbar from './DraftToolbar';
import VersionHistory from './VersionHistory';
import BoilerplateDrawer from '@/components/shared/BoilerplateDrawer';
/* ─── Types ──────────────────────────────────────────────────────────────── */
export interface DraftData {
id: string;
session_id: string;
lane: LaneKey;
subject: string;
body_html: string;
body_text?: string;
status: DraftStatus;
current_version: number;
}
export type DraftStatus = 'draft' | 'review' | 'approved' | 'sent';
interface DraftEditorProps {
draft: DraftData;
onClose: () => void;
onDraftUpdated: (updated: Partial<DraftData>) => void;
}
const STATUS_OPTIONS: { value: DraftStatus; label: string }[] = [
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'In Review' },
{ value: 'approved', label: 'Approved' },
{ value: 'sent', label: 'Sent' },
];
/* ─── Strip HTML tags to plain text (no DOM manipulation) ───────────────── */
function htmlToPlainText(html: string | undefined | null): string {
if (!html) return '';
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<\/li>/gi, '\n')
.replace(/<\/h[1-6]>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/ /g, ' ')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/* ─── DraftEditor ────────────────────────────────────────────────────────── */
export default function DraftEditor({ draft, onClose, onDraftUpdated }: DraftEditorProps) {
const { org } = useOrg();
const lanes = org?.lanes ?? {};
const lane = lanes[draft.lane] ?? { label: draft.lane, color: '#888', description: '' };
const laneClass = `lane-${draft.lane.toLowerCase()}` as 'lane-a' | 'lane-b' | 'lane-c';
const [subject, setSubject] = useState(draft.subject);
const [bodyHtml, setBodyHtml] = useState(draft.body_html);
const [status, setStatus] = useState<DraftStatus>(draft.status);
const [mode, setMode] = useState<EditorMode>('wysiwyg');
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showVersionHistory, setShowVersionHistory] = useState(false);
const [showBoilerplate, setShowBoilerplate] = useState(false);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestBodyRef = useRef(bodyHtml);
const latestSubjectRef = useRef(subject);
const latestStatusRef = useRef(status);
/* Keep refs in sync */
useEffect(() => { latestBodyRef.current = bodyHtml; }, [bodyHtml]);
useEffect(() => { latestSubjectRef.current = subject; }, [subject]);
useEffect(() => { latestStatusRef.current = status; }, [status]);
/* ── Persist ───────────────────────────────────────────────────────────── */
const persist = useCallback(async () => {
setIsSaving(true);
try {
const res = await fetch(`/api/drafts/${draft.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: latestSubjectRef.current,
body_html: latestBodyRef.current,
body_text: htmlToPlainText(latestBodyRef.current),
status: latestStatusRef.current,
}),
});
if (!res.ok) throw new Error('Save failed');
const updated = await res.json();
setIsDirty(false);
onDraftUpdated({
subject: latestSubjectRef.current,
body_html: latestBodyRef.current,
status: latestStatusRef.current,
...(updated.draft ?? {}),
});
} catch {
/* Non-blocking — user can retry via manual save */
} finally {
setIsSaving(false);
}
}, [draft.id, onDraftUpdated]);
function scheduleAutoSave() {
setIsDirty(true);
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(persist, 2000);
}
useEffect(() => {
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
}, []);
/* ── Event handlers ────────────────────────────────────────────────────── */
function handleBodyChange(html: string) {
setBodyHtml(html);
scheduleAutoSave();
}
function handleSubjectChange(e: React.ChangeEvent<HTMLInputElement>) {
setSubject(e.target.value);
scheduleAutoSave();
}
function handleStatusChange(e: React.ChangeEvent<HTMLSelectElement>) {
setStatus(e.target.value as DraftStatus);
scheduleAutoSave();
}
function handleManualSave() {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
persist();
}
function handleBoilerplateInsert(html: string) {
setBodyHtml((prev) => prev + html);
scheduleAutoSave();
}
function handleRestore(version: { subject: string; body_html: string }) {
setSubject(version.subject);
setBodyHtml(version.body_html);
setIsDirty(true);
setShowVersionHistory(false);
}
const plainText = htmlToPlainText(bodyHtml);
return (
<>
<div
className="flex flex-col"
style={{
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
overflow: 'hidden',
}}
>
{/* ── Editor Header ─────────────────────────────────────────────── */}
<div
className="flex items-center gap-3 px-3 py-2.5 flex-wrap"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<span className={`badge ${laneClass}`} aria-label={`Lane ${draft.lane}: ${lane.label}`}>
{draft.lane} — {lane.label}
</span>
<input
type="text"
value={subject}
onChange={handleSubjectChange}
placeholder="Subject line..."
className="input flex-1 min-w-0"
style={{ fontSize: '0.9375rem', fontWeight: 500 }}
aria-label="Email subject line"
/>
<div className="flex items-center gap-2 shrink-0">
{/* Status selector */}
<div className="relative">
<select
value={status}
onChange={handleStatusChange}
className="input pr-7 appearance-none text-xs"
style={{ paddingTop: '0.3rem', paddingBottom: '0.3rem', cursor: 'pointer' }}
aria-label="Draft status"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<ChevronDown
size={12}
className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--color-text-muted)' }}
aria-hidden="true"
/>
</div>
<EditorToggle mode={mode} onModeChange={setMode} />
<button
type="button"
onClick={onClose}
className="btn btn-ghost btn-sm"
aria-label="Collapse editor"
title="Collapse editor"
>
<X size={14} />
</button>
</div>
</div>
{/* ── Editor body ───────────────────────────────────────────────── */}
<div className="p-3">
{mode === 'wysiwyg' && (
<RichTextEditor
content={bodyHtml}
onChange={handleBodyChange}
placeholder={`Write your ${lane.label} email...`}
/>
)}
{mode === 'html' && (
<HtmlSourceEditor
value={bodyHtml}
onChange={handleBodyChange}
/>
)}
{mode === 'plaintext' && (
<div
className="p-3 rounded-lg overflow-y-auto"
style={{
minHeight: 300,
maxHeight: 600,
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
color: 'var(--color-text-secondary)',
fontSize: '0.875rem',
lineHeight: 1.7,
fontFamily: 'ui-monospace, "Cascadia Code", Menlo, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
role="region"
aria-label="Plain text preview (read-only)"
aria-readonly="true"
>
{plainText || (
<span style={{ color: 'var(--color-text-muted)' }}>
Plain text preview will appear here once you add content above.
</span>
)}
</div>
)}
</div>
{/* ── Version history inline panel ──────────────────────────────── */}
{showVersionHistory && (
<div
style={{
borderTop: '1px solid var(--color-border)',
maxHeight: 320,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<VersionHistory draftId={draft.id} onRestore={handleRestore} />
</div>
)}
{/* ── Toolbar ───────────────────────────────────────────────────── */}
<DraftToolbar
draftId={draft.id}
isSaving={isSaving}
isDirty={isDirty}
onSave={handleManualSave}
onToggleBoilerplate={() => setShowBoilerplate((v) => !v)}
onToggleVersionHistory={() => setShowVersionHistory((v) => !v)}
showVersionHistory={showVersionHistory}
/>
</div>
{/* ── Boilerplate slide-out drawer ──────────────────────────────────── */}
<BoilerplateDrawer
isOpen={showBoilerplate}
onClose={() => setShowBoilerplate(false)}
onInsert={handleBoilerplateInsert}
/>
</>
);
}