← back to Norma

components/email-analyzer/AnalyzerChat.tsx

610 lines

'use client';

import { useState, useRef, useEffect } from 'react';
import {
  Send,
  Loader2,
  User,
  Bot,
  Plus,
  Sliders,
  RotateCcw,
} from 'lucide-react';

/* ─── Types ──────────────────────────────────────────────────────────────── */

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  timestamp: Date;
}

interface AnalyzerChatProps {
  messages: ChatMessage[];
  onSendMessage: (message: string) => void;
  onQuickAction: (action: { type: string; value: string }) => void;
  isProcessing: boolean;
  hasEmail: boolean;
}

/* ─── Slider dials ───────────────────────────────────────────────────────── */
/* Each dial is 1-5, default 3 ("no change on this axis"). The rewrite      */
/* instruction only includes dials the user moved off the center position.   */

interface DialDef {
  id: keyof DialState;
  label: string;
  left: string;
  right: string;
  /** labels[0..4] correspond to values 1..5 */
  labels: [string, string, string, string, string];
  /** instruction fragment for each value, empty string for no-op */
  instructions: [string, string, string, string, string];
}

interface DialState {
  length: number;
  sentiment: number;
  tone: number;
  urgency: number;
  donation: number;
}

const DIAL_DEFAULTS: DialState = {
  length: 3,
  sentiment: 3,
  tone: 3,
  urgency: 3,
  donation: 3,
};

const DIALS: DialDef[] = [
  {
    id: 'length',
    label: 'Length',
    left: 'Shorter',
    right: 'Longer',
    labels: ['Much shorter', 'Shorter', 'Same', 'Longer', 'Much longer'],
    instructions: [
      'make the email significantly shorter and more concise, cutting anything non-essential',
      'make the email somewhat shorter and tighter',
      '',
      'expand the email with more detail and supporting context',
      'significantly expand the email with rich detail, context, and supporting points',
    ],
  },
  {
    id: 'sentiment',
    label: 'Sentiment',
    left: 'Critical',
    right: 'Positive',
    labels: ['Very critical', 'Skeptical', 'Neutral', 'Warm', 'Very positive'],
    instructions: [
      'shift the sentiment to be strongly critical and direct about the problem',
      'shift the sentiment to be more skeptical and measured',
      '',
      'shift the sentiment to be warmer and more optimistic',
      'shift the sentiment to be strongly positive, hopeful, and uplifting',
    ],
  },
  {
    id: 'tone',
    label: 'Tone',
    left: 'Formal',
    right: 'Casual',
    labels: ['Very formal', 'Formal', 'Neutral', 'Casual', 'Very casual'],
    instructions: [
      'use a very formal, professional tone suitable for executive or legal audiences',
      'use a somewhat more formal, polished tone',
      '',
      'use a more casual, conversational tone',
      'use a very casual, warm, conversational tone as if speaking to a friend',
    ],
  },
  {
    id: 'urgency',
    label: 'Urgency',
    left: 'Calm',
    right: 'Urgent',
    labels: ['Very calm', 'Calm', 'Neutral', 'Urgent', 'Very urgent'],
    instructions: [
      'make the email feel very calm and measured, no time pressure',
      'reduce the urgency, make it feel less rushed',
      '',
      'increase the urgency and add a clear call-to-action',
      'make the email feel highly urgent with a strong deadline-driven call-to-action',
    ],
  },
  {
    id: 'donation',
    label: 'Donation Ask',
    left: 'None',
    right: 'Strong ask',
    labels: ['Remove ask', 'Soft mention', 'No change', 'Direct ask', 'Strong ask'],
    instructions: [
      'remove any donation or fundraising ask entirely',
      'soften any donation ask to a subtle, indirect mention',
      '',
      'add a clear, direct donation ask with a suggested amount and a call-to-action link',
      'add a strong, urgent donation ask with multiple suggested amounts, a deadline, and a prominent call-to-action',
    ],
  },
];

function buildInstructionFromDials(state: DialState): string {
  const parts: string[] = [];
  for (const dial of DIALS) {
    const v = state[dial.id];
    if (v === 3) continue;
    const frag = dial.instructions[v - 1];
    if (frag) parts.push(frag);
  }
  if (parts.length === 0) return '';
  return `Please rewrite the email with the following adjustments:\n- ${parts.join('\n- ')}`;
}

/* ─── Component ──────────────────────────────────────────────────────────── */

export default function AnalyzerChat({
  messages,
  onSendMessage,
  onQuickAction,
  isProcessing,
  hasEmail,
}: AnalyzerChatProps) {
  const [input, setInput] = useState('');
  const [addTopicInput, setAddTopicInput] = useState('');
  const [showAddTopic, setShowAddTopic] = useState(false);
  const [dials, setDials] = useState<DialState>(DIAL_DEFAULTS);
  const chatEndRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLTextAreaElement>(null);

  const dialsDirty = (Object.keys(DIAL_DEFAULTS) as Array<keyof DialState>).some(
    (k) => dials[k] !== DIAL_DEFAULTS[k],
  );

  function updateDial(id: keyof DialState, value: number): void {
    setDials((prev) => ({ ...prev, [id]: value }));
  }

  function resetDials(): void {
    setDials(DIAL_DEFAULTS);
  }

  function applyDials(): void {
    const instruction = buildInstructionFromDials(dials);
    if (!instruction || isProcessing) return;
    onSendMessage(instruction);
    resetDials();
  }

  // Auto-scroll to bottom on new messages
  useEffect(() => {
    chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // Auto-focus input after AI responds
  useEffect(() => {
    if (!isProcessing && hasEmail) {
      inputRef.current?.focus();
    }
  }, [isProcessing, hasEmail]);

  function handleSend() {
    const trimmed = input.trim();
    if (!trimmed || isProcessing) return;
    onSendMessage(trimmed);
    setInput('');
  }

  function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
      e.preventDefault();
      handleSend();
    }
  }

  function handleAddTopic() {
    const trimmed = addTopicInput.trim();
    if (!trimmed) return;
    onQuickAction({ type: 'add-paragraph', value: trimmed });
    setAddTopicInput('');
    setShowAddTopic(false);
  }

  return (
    <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
      {/* ── Chat Messages ──────────────────────────────────────────────── */}
      <div
        style={{
          flex: 1,
          overflowY: 'auto',
          padding: '12px 0',
          display: 'flex',
          flexDirection: 'column',
          gap: 10,
        }}
      >
        {messages.length === 0 && (
          <div
            style={{
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
              justifyContent: 'center',
              padding: '32px 16px',
              gap: 8,
            }}
          >
            <Bot
              size={28}
              style={{ color: 'var(--color-text-muted)' }}
            />
            <p
              style={{
                fontSize: 14,
                color: 'var(--color-text-muted)',
                textAlign: 'center',
                lineHeight: 1.5,
              }}
            >
              {hasEmail
                ? 'Tell me how to improve this email. Try: "Make it more formal" or "Add a call to action"'
                : 'Paste an email on the left to get started'}
            </p>
          </div>
        )}

        {messages.map((msg) => (
          <div
            key={msg.id}
            style={{
              display: 'flex',
              flexDirection: msg.role === 'user' ? 'row-reverse' : 'row',
              gap: 8,
              padding: '0 4px',
            }}
          >
            {/* Avatar */}
            <div
              style={{
                width: 28,
                height: 28,
                borderRadius: '50%',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                flexShrink: 0,
                backgroundColor:
                  msg.role === 'user'
                    ? 'var(--color-primary-dim)'
                    : 'var(--color-surface-el)',
                border: `1px solid ${msg.role === 'user' ? 'rgba(10,124,89,0.25)' : 'var(--color-border)'}`,
              }}
            >
              {msg.role === 'user' ? (
                <User size={14} style={{ color: 'var(--color-primary)' }} />
              ) : (
                <Bot size={14} style={{ color: 'var(--color-text-muted)' }} />
              )}
            </div>

            {/* Bubble */}
            <div
              style={{
                maxWidth: '85%',
                padding: '8px 12px',
                borderRadius: 10,
                fontSize: 13,
                lineHeight: 1.5,
                color: 'var(--color-text)',
                backgroundColor:
                  msg.role === 'user'
                    ? 'var(--color-primary-dim)'
                    : 'var(--color-surface-el)',
                border: `1px solid ${msg.role === 'user' ? 'rgba(10,124,89,0.2)' : 'var(--color-border)'}`,
                whiteSpace: 'pre-wrap',
                wordBreak: 'break-word',
              }}
            >
              {msg.content}
            </div>
          </div>
        ))}

        {isProcessing && (
          <div style={{ display: 'flex', gap: 8, padding: '0 4px' }}>
            <div
              style={{
                width: 28,
                height: 28,
                borderRadius: '50%',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                flexShrink: 0,
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
              }}
            >
              <Bot size={14} style={{ color: 'var(--color-text-muted)' }} />
            </div>
            <div
              style={{
                padding: '8px 12px',
                borderRadius: 10,
                fontSize: 13,
                color: 'var(--color-text-muted)',
                backgroundColor: 'var(--color-surface-el)',
                border: '1px solid var(--color-border)',
                display: 'flex',
                alignItems: 'center',
                gap: 8,
              }}
            >
              <Loader2 size={14} className="spinner" />
              Rewriting...
            </div>
          </div>
        )}

        <div ref={chatEndRef} />
      </div>

      {/* ── Quick Actions ──────────────────────────────────────────────── */}
      {hasEmail && (
        <div
          style={{
            padding: '8px 0',
            borderTop: '1px solid var(--color-border)',
            display: 'flex',
            flexDirection: 'column',
            gap: 8,
          }}
        >
          {/* Rewrite dials (sliders) */}
          <div>
            <div
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 6,
                marginBottom: 6,
              }}
            >
              <Sliders size={12} style={{ color: 'var(--color-text-muted)' }} />
              <div
                style={{
                  fontSize: 11,
                  fontWeight: 600,
                  color: 'var(--color-text-muted)',
                  textTransform: 'uppercase',
                  letterSpacing: '0.06em',
                }}
              >
                Rewrite Dials
              </div>
              {dialsDirty && (
                <button
                  type="button"
                  className="btn btn-ghost btn-sm"
                  onClick={resetDials}
                  disabled={isProcessing}
                  style={{
                    fontSize: 10,
                    padding: '2px 8px',
                    minHeight: 22,
                    gap: 3,
                    marginLeft: 'auto',
                  }}
                  title="Reset all dials to center"
                >
                  <RotateCcw size={10} /> Reset
                </button>
              )}
            </div>

            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {DIALS.map((dial) => {
                const value = dials[dial.id];
                const isDefault = value === 3;
                const currentLabel = dial.labels[value - 1];
                return (
                  <div key={dial.id}>
                    <div
                      style={{
                        display: 'flex',
                        justifyContent: 'space-between',
                        alignItems: 'baseline',
                        marginBottom: 2,
                      }}
                    >
                      <label
                        style={{
                          fontSize: 10,
                          fontWeight: 600,
                          color: 'var(--color-text-muted)',
                          textTransform: 'uppercase',
                          letterSpacing: '0.05em',
                        }}
                      >
                        {dial.label}
                      </label>
                      <span
                        style={{
                          fontSize: 11,
                          fontWeight: 700,
                          color: isDefault
                            ? 'var(--color-text-muted)'
                            : 'var(--color-primary)',
                        }}
                      >
                        {currentLabel}
                      </span>
                    </div>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                      <span
                        style={{
                          fontSize: 9,
                          color: 'var(--color-text-muted)',
                          minWidth: 48,
                          textAlign: 'right',
                        }}
                      >
                        {dial.left}
                      </span>
                      <input
                        type="range"
                        min={1}
                        max={5}
                        step={1}
                        value={value}
                        disabled={isProcessing}
                        onChange={(e) => updateDial(dial.id, Number(e.target.value))}
                        style={{
                          flex: 1,
                          accentColor: isDefault
                            ? 'var(--color-text-muted)'
                            : 'var(--color-primary)',
                        }}
                      />
                      <span
                        style={{
                          fontSize: 9,
                          color: 'var(--color-text-muted)',
                          minWidth: 56,
                        }}
                      >
                        {dial.right}
                      </span>
                    </div>
                  </div>
                );
              })}
            </div>

            <button
              type="button"
              className="btn btn-primary btn-sm"
              disabled={!dialsDirty || isProcessing}
              onClick={applyDials}
              style={{
                marginTop: 8,
                fontSize: 12,
                padding: '6px 12px',
                width: '100%',
                justifyContent: 'center',
              }}
            >
              {isProcessing ? (
                <>
                  <Loader2 size={12} className="spinner" /> Rewriting...
                </>
              ) : (
                <>Apply Dial Changes</>
              )}
            </button>
          </div>

          {/* Add paragraph about ... */}
          <div>
            {showAddTopic ? (
              <div style={{ display: 'flex', gap: 4 }}>
                <input
                  type="text"
                  className="input"
                  style={{ fontSize: 13, minHeight: 34, padding: '6px 10px', flex: 1 }}
                  placeholder="e.g. upcoming webinar on March 25th"
                  value={addTopicInput}
                  onChange={(e) => setAddTopicInput(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === 'Enter') { e.preventDefault(); handleAddTopic(); }
                    if (e.key === 'Escape') setShowAddTopic(false);
                  }}
                  autoFocus
                />
                <button
                  type="button"
                  className="btn btn-primary btn-sm"
                  style={{ minHeight: 34, padding: '4px 10px', fontSize: 12 }}
                  disabled={isProcessing || !addTopicInput.trim()}
                  onClick={handleAddTopic}
                >
                  Add
                </button>
              </div>
            ) : (
              <button
                type="button"
                disabled={isProcessing}
                onClick={() => setShowAddTopic(true)}
                className="btn btn-ghost btn-sm"
                style={{
                  fontSize: 12,
                  padding: '4px 10px',
                  minHeight: 30,
                  gap: 4,
                  borderColor: 'var(--color-border)',
                }}
              >
                <Plus size={12} />
                Add paragraph about...
              </button>
            )}
          </div>
        </div>
      )}

      {/* ── Chat Input ─────────────────────────────────────────────────── */}
      <div
        style={{
          padding: '8px 0 0',
          borderTop: '1px solid var(--color-border)',
          display: 'flex',
          gap: 6,
          alignItems: 'flex-end',
        }}
      >
        <textarea
          ref={inputRef}
          className="input"
          style={{
            flex: 1,
            fontSize: 13,
            minHeight: 40,
            maxHeight: 120,
            resize: 'none',
            padding: '8px 12px',
          }}
          placeholder={hasEmail ? 'Tell me how to change the email... (Ctrl+Enter to send)' : 'Paste an email first...'}
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={handleKeyDown}
          disabled={!hasEmail || isProcessing}
          rows={1}
        />
        <button
          type="button"
          className="btn btn-primary btn-sm"
          style={{
            minHeight: 40,
            width: 40,
            padding: 0,
            flexShrink: 0,
          }}
          disabled={!input.trim() || isProcessing || !hasEmail}
          onClick={handleSend}
          title="Send (Ctrl+Enter)"
        >
          {isProcessing ? (
            <Loader2 size={16} className="spinner" />
          ) : (
            <Send size={16} />
          )}
        </button>
      </div>
    </div>
  );
}