← back to Norma

components/ChatPanel.tsx

464 lines

'use client';

import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { MessageSquare, Sparkles, X, Send } from 'lucide-react';

/* ─── Types ─────────────────────────────────────────────────────────────── */
interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  created_at: string;
}

/* ─── Relative time helper ──────────────────────────────────────────────── */
function relativeTime(dateStr: string): string {
  const now = Date.now();
  const then = new Date(dateStr).getTime();
  const diffMs = now - then;
  const diffSec = Math.floor(diffMs / 1000);
  if (diffSec < 10) return 'just now';
  if (diffSec < 60) return `${diffSec}s ago`;
  const diffMin = Math.floor(diffSec / 60);
  if (diffMin < 60) return `${diffMin}m ago`;
  const diffHr = Math.floor(diffMin / 60);
  if (diffHr < 24) return `${diffHr}h ago`;
  const diffDay = Math.floor(diffHr / 24);
  return `${diffDay}d ago`;
}

/* ─── ChatPanel component ───────────────────────────────────────────────── */
export default function ChatPanel() {
  const [expanded, setExpanded] = useState(false);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);
  const [historyLoaded, setHistoryLoaded] = useState(false);

  const messagesEndRef = useRef<HTMLDivElement>(null);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  /* ── Auto-scroll to bottom ────────────────────────────────────────────── */
  const scrollToBottom = useCallback(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, []);

  useEffect(() => {
    scrollToBottom();
  }, [messages, loading, scrollToBottom]);

  /* ── Load chat history on first expand ────────────────────────────────── */
  useEffect(() => {
    if (expanded && !historyLoaded) {
      (async () => {
        try {
          const res = await fetch('/api/ai-chat');
          if (res.ok) {
            const data = await res.json();
            setMessages(data.messages || []);
          }
        } catch (err) {
          console.error('[ChatPanel] Failed to load history:', err);
        } finally {
          setHistoryLoaded(true);
        }
      })();
    }
  }, [expanded, historyLoaded]);

  /* ── Focus textarea when expanded ─────────────────────────────────────── */
  useEffect(() => {
    if (expanded && historyLoaded) {
      setTimeout(() => textareaRef.current?.focus(), 100);
    }
  }, [expanded, historyLoaded]);

  /* ── Escape key closes panel ──────────────────────────────────────────── */
  useEffect(() => {
    const handleEsc = (e: globalThis.KeyboardEvent) => {
      if (e.key === 'Escape' && expanded) setExpanded(false);
    };
    window.addEventListener('keydown', handleEsc);
    return () => window.removeEventListener('keydown', handleEsc);
  }, [expanded]);

  /* ── Send message ─────────────────────────────────────────────────────── */
  async function sendMessage() {
    const text = input.trim();
    if (!text || loading) return;

    const userMsg: ChatMessage = {
      id: `tmp-user-${Date.now()}`,
      role: 'user',
      content: text,
      created_at: new Date().toISOString(),
    };

    setMessages((prev) => [...prev, userMsg]);
    setInput('');
    setLoading(true);

    // Reset textarea height
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto';
    }

    try {
      const res = await fetch('/api/ai-chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ message: text }),
      });

      const data = await res.json();

      if (res.ok && data.reply) {
        const aiMsg: ChatMessage = {
          id: `tmp-ai-${Date.now()}`,
          role: 'assistant',
          content: data.reply,
          created_at: new Date().toISOString(),
        };
        setMessages((prev) => [...prev, aiMsg]);
      } else {
        const errMsg: ChatMessage = {
          id: `tmp-err-${Date.now()}`,
          role: 'assistant',
          content: data.error || 'Something went wrong. Please try again.',
          created_at: new Date().toISOString(),
        };
        setMessages((prev) => [...prev, errMsg]);
      }
    } catch (err) {
      const errMsg: ChatMessage = {
        id: `tmp-err-${Date.now()}`,
        role: 'assistant',
        content: 'Network error. Please check your connection.',
        created_at: new Date().toISOString(),
      };
      setMessages((prev) => [...prev, errMsg]);
    } finally {
      setLoading(false);
      setTimeout(() => textareaRef.current?.focus(), 50);
    }
  }

  /* ── Keyboard handling for textarea ───────────────────────────────────── */
  function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  }

  /* ── Auto-resize textarea ─────────────────────────────────────────────── */
  function handleTextareaChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
    setInput(e.target.value);
    const ta = e.target;
    ta.style.height = 'auto';
    ta.style.height = Math.min(ta.scrollHeight, 96) + 'px'; // max ~4 lines
  }

  /* ═══ Collapsed: fab button ═══════════════════════════════════════════── */
  if (!expanded) {
    return (
      <button
        onClick={() => setExpanded(true)}
        aria-label="Open Norma Assistant"
        style={{
          position: 'fixed',
          left: 24,
          bottom: 24,
          zIndex: 50,
          width: 48,
          height: 48,
          borderRadius: '50%',
          background: 'linear-gradient(135deg, #34d399, #10b981)',
          border: 'none',
          cursor: 'pointer',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          boxShadow: '0 4px 16px rgba(16,185,129,0.4)',
          transition: 'transform 0.15s ease, box-shadow 0.15s ease',
          animation: 'chat-fab-pulse 2s ease-in-out 1',
        }}
        onMouseEnter={(e) => {
          e.currentTarget.style.transform = 'scale(1.08)';
          e.currentTarget.style.boxShadow = '0 6px 24px rgba(16,185,129,0.55)';
        }}
        onMouseLeave={(e) => {
          e.currentTarget.style.transform = 'scale(1)';
          e.currentTarget.style.boxShadow = '0 4px 16px rgba(16,185,129,0.4)';
        }}
      >
        <MessageSquare size={22} color="#fff" />

        {/* Pulse keyframe (injected once) */}
        <style>{`
          @keyframes chat-fab-pulse {
            0%, 100% { box-shadow: 0 4px 16px rgba(16,185,129,0.4); }
            50% { box-shadow: 0 4px 28px rgba(16,185,129,0.7); }
          }
          @keyframes chat-dots {
            0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
            40% { opacity: 1; transform: translateY(-4px); }
          }
        `}</style>
      </button>
    );
  }

  /* ═══ Expanded: chat panel ════════════════════════════════════════════── */
  return (
    <div
      style={{
        position: 'fixed',
        left: 24,
        bottom: 24,
        zIndex: 50,
        width: 380,
        minHeight: 500,
        maxHeight: '70vh',
        display: 'flex',
        flexDirection: 'column',
        borderRadius: 16,
        backgroundColor: 'var(--color-surface)',
        border: '1px solid var(--color-border)',
        boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
        overflow: 'hidden',
      }}
    >
      {/* ── Header ────────────────────────────────────────────────────────── */}
      <div
        style={{
          background: 'linear-gradient(135deg, #34d399, #10b981)',
          padding: '12px 16px',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'space-between',
          flexShrink: 0,
        }}
      >
        <div className="flex items-center gap-2">
          <Sparkles size={16} color="#fff" />
          <span style={{ color: '#fff', fontWeight: 600, fontSize: '0.875rem' }}>
            Norma Assistant
          </span>
        </div>
        <button
          onClick={() => setExpanded(false)}
          aria-label="Close chat"
          style={{
            background: 'rgba(255,255,255,0.15)',
            border: 'none',
            borderRadius: 6,
            cursor: 'pointer',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            width: 28,
            height: 28,
            transition: 'background 0.15s',
          }}
          onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
          onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
        >
          <X size={16} color="#fff" />
        </button>
      </div>

      {/* ── Messages area ─────────────────────────────────────────────────── */}
      <div
        className="flex-1"
        style={{
          overflowY: 'auto',
          padding: '12px 14px',
          display: 'flex',
          flexDirection: 'column',
          gap: 10,
        }}
      >
        {messages.length === 0 && !loading && (
          <div
            style={{
              textAlign: 'center',
              padding: '40px 16px',
              color: 'var(--color-text-muted)',
              fontSize: '0.8125rem',
              lineHeight: 1.5,
            }}
          >
            <Sparkles size={28} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
            <div style={{ fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
              Norma Assistant
            </div>
            Ask me about drafts, grants, partners, news, or anything civic action related.
          </div>
        )}

        {messages.map((msg) => (
          <div
            key={msg.id}
            style={{
              display: 'flex',
              flexDirection: 'column',
              alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
              maxWidth: '100%',
            }}
          >
            <div
              style={{
                maxWidth: '85%',
                padding: '10px 14px',
                fontSize: '0.8125rem',
                lineHeight: 1.55,
                whiteSpace: 'pre-wrap',
                wordBreak: 'break-word',
                ...(msg.role === 'user'
                  ? {
                      backgroundColor: '#10b981',
                      color: '#fff',
                      borderRadius: '16px 16px 4px 16px',
                    }
                  : {
                      backgroundColor: 'var(--color-surface-el)',
                      color: 'var(--color-text)',
                      borderRadius: '16px 16px 16px 4px',
                    }),
              }}
            >
              {msg.content}
            </div>
            <span
              style={{
                fontSize: '0.625rem',
                color: 'var(--color-text-muted)',
                marginTop: 3,
                paddingLeft: msg.role === 'assistant' ? 4 : 0,
                paddingRight: msg.role === 'user' ? 4 : 0,
              }}
            >
              {relativeTime(msg.created_at)}
            </span>
          </div>
        ))}

        {/* Thinking dots */}
        {loading && (
          <div style={{ display: 'flex', alignItems: 'flex-start' }}>
            <div
              style={{
                backgroundColor: 'var(--color-surface-el)',
                borderRadius: '16px 16px 16px 4px',
                padding: '12px 18px',
                display: 'flex',
                gap: 5,
                alignItems: 'center',
              }}
            >
              {[0, 1, 2].map((i) => (
                <span
                  key={i}
                  style={{
                    width: 7,
                    height: 7,
                    borderRadius: '50%',
                    backgroundColor: 'var(--color-text-muted)',
                    display: 'inline-block',
                    animation: `chat-dots 1.4s ease-in-out ${i * 0.16}s infinite`,
                  }}
                />
              ))}
            </div>
          </div>
        )}

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

      {/* ── Input area ────────────────────────────────────────────────────── */}
      <div
        style={{
          borderTop: '1px solid var(--color-border)',
          padding: '10px 12px',
          display: 'flex',
          gap: 8,
          alignItems: 'flex-end',
          flexShrink: 0,
          backgroundColor: 'var(--color-surface)',
        }}
      >
        <textarea
          ref={textareaRef}
          value={input}
          onChange={handleTextareaChange}
          onKeyDown={handleKeyDown}
          placeholder="Ask about civic action data..."
          aria-label="Chat message"
          disabled={loading}
          rows={1}
          style={{
            flex: 1,
            resize: 'none',
            border: '1px solid var(--color-border)',
            borderRadius: 10,
            padding: '8px 12px',
            fontSize: '0.8125rem',
            lineHeight: 1.4,
            backgroundColor: 'var(--color-surface-el)',
            color: 'var(--color-text)',
            outline: 'none',
            overflow: 'hidden',
            transition: 'border-color 0.15s',
            maxHeight: 96,
          }}
          onFocus={(e) => { e.currentTarget.style.borderColor = '#10b981'; }}
          onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
        />
        <button
          onClick={sendMessage}
          disabled={loading || !input.trim()}
          aria-label="Send message"
          style={{
            width: 36,
            height: 36,
            borderRadius: 10,
            border: 'none',
            cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: loading || !input.trim() ? 'var(--color-surface-el)' : '#10b981',
            transition: 'background-color 0.15s',
            flexShrink: 0,
          }}
          onMouseEnter={(e) => {
            if (!loading && input.trim()) {
              e.currentTarget.style.backgroundColor = '#059669';
            }
          }}
          onMouseLeave={(e) => {
            if (!loading && input.trim()) {
              e.currentTarget.style.backgroundColor = '#10b981';
            }
          }}
        >
          <Send
            size={16}
            color={loading || !input.trim() ? 'var(--color-text-muted)' : '#fff'}
          />
        </button>
      </div>

      {/* Inject animation keyframes */}
      <style>{`
        @keyframes chat-dots {
          0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
          40% { opacity: 1; transform: translateY(-4px); }
        }
      `}</style>
    </div>
  );
}