Custom i18n system with typed translation dictionaries (~570 keys), LanguageProvider context, and useTranslation hook. All 31 components and pages wired with t() calls. Chatbot backend passes language hint to Claude for Ukrainian responses. Language preference persists via localStorage. SEO meta tags and html lang attribute update dynamically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { useTranslation } from '../i18n';
|
|
import ChatMessage from './ChatMessage';
|
|
import ChatInput from './ChatInput';
|
|
import ChatLeadForm from './ChatLeadForm';
|
|
import type { LeadInfo } from './ChatLeadForm';
|
|
import type { ChatMessage as ChatMessageType } from '../types/chat';
|
|
import './ChatWindow.css';
|
|
|
|
interface ChatWindowProps {
|
|
messages: ChatMessageType[];
|
|
loading: boolean;
|
|
rateLimited: boolean;
|
|
hasLead: boolean;
|
|
onSend: (text: string) => void;
|
|
onClose: () => void;
|
|
onClear: () => void;
|
|
onLeadSubmit: (lead: LeadInfo) => void;
|
|
}
|
|
|
|
const ChatWindow: React.FC<ChatWindowProps> = ({
|
|
messages,
|
|
loading,
|
|
rateLimited,
|
|
hasLead,
|
|
onSend,
|
|
onClose,
|
|
onClear,
|
|
onLeadSubmit,
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, loading]);
|
|
|
|
return (
|
|
<motion.div
|
|
className="chat-window"
|
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
|
>
|
|
{!hasLead ? (
|
|
<>
|
|
<div className="chat-window__header">
|
|
<div className="chat-window__header-info" />
|
|
<div className="chat-window__header-actions">
|
|
<button
|
|
className="chat-window__action-btn"
|
|
onClick={onClose}
|
|
aria-label={t('chat.closeChat')}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<ChatLeadForm onSubmit={onLeadSubmit} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="chat-window__header">
|
|
<div className="chat-window__header-info">
|
|
<div className="chat-window__avatar">AI</div>
|
|
<div>
|
|
<div className="chat-window__title">{t('chat.headerTitle')}</div>
|
|
<div className="chat-window__status">{t('chat.status')}</div>
|
|
</div>
|
|
</div>
|
|
<div className="chat-window__header-actions">
|
|
<button
|
|
className="chat-window__action-btn"
|
|
onClick={onClear}
|
|
aria-label={t('chat.clearChat')}
|
|
title={t('chat.clearChat')}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
|
<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
className="chat-window__action-btn"
|
|
onClick={onClose}
|
|
aria-label={t('chat.closeChat')}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="18" height="18">
|
|
<path d="M18 6L6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chat-window__messages">
|
|
{messages.length === 0 && (
|
|
<div className="chat-window__welcome">
|
|
<p>{t('chat.welcome')}</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg) => (
|
|
<ChatMessage key={msg.id} message={msg} />
|
|
))}
|
|
{loading && (
|
|
<div className="chat-window__typing">
|
|
<span /><span /><span />
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<ChatInput onSend={onSend} disabled={loading || rateLimited} />
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default ChatWindow;
|