← 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(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&nbsp;/g, ' ')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/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}
      />
    </>
  );
}