| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- import * as React from 'react';
- import { Conversation, AnalyticsData, ChatMessage } from '../types';
- import { subDays, format, eachDayOfInterval, startOfDay } from 'date-fns';
- import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
- import { Icon } from './ui/Icon';
- import { useTranslation } from '../hooks/useI18n';
- const STOP_WORDS_LIST = ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', 'her', 'hers', 'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves', 'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just', 'don', 'should', 'now'];
- const STOP_WORDS = new Set(STOP_WORDS_LIST);
- const EditResponseModal: React.FC<{
- message: ChatMessage;
- onClose: () => void;
- onSave: (newText: string) => void;
- }> = ({ message, onClose, onSave }) => {
- const { t } = useTranslation();
- const [text, setText] = React.useState(message.text);
- return (
- <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
- <div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md" onClick={e => e.stopPropagation()}>
- <div className="p-6 border-b border-gray-200 dark:border-gray-700">
- <h3 className="text-lg font-bold text-gray-900 dark:text-white">{t('analytics.interactions.edit_response_title')}</h3>
- </div>
- <div className="p-6">
- <textarea value={text} onChange={e => setText(e.target.value)} rows={5} className="w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600" />
- </div>
- <div className="p-4 bg-gray-50 dark:bg-gray-900/50 flex justify-end gap-3">
- <button onClick={onClose} className="px-4 py-2 rounded-md text-sm font-semibold text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600">{t('cancel')}</button>
- <button onClick={() => onSave(text)} className="px-4 py-2 rounded-md text-sm font-semibold text-white bg-brand-primary hover:bg-brand-secondary">{t('analytics.interactions.save_and_update')}</button>
- </div>
- </div>
- </div>
- );
- };
- interface InteractionAnalyticsProps {
- analyticsData: AnalyticsData;
- onUpdateConversations: (conversations: Conversation[]) => void;
- }
- const InteractionAnalytics: React.FC<InteractionAnalyticsProps> = ({ analyticsData, onUpdateConversations }) => {
- // FIX: Destructure 'language' from useTranslation hook to make it available for 'toLocaleString'.
- const { t, dateLocale, language } = useTranslation();
- const { conversations } = analyticsData;
- const [dateFilter, setDateFilter] = React.useState<string>('28');
- const [statusFilter, setStatusFilter] = React.useState<'all' | 'open' | 'resolved'>('all');
- const [searchTerm, setSearchTerm] = React.useState('');
- const [isDarkMode, setIsDarkMode] = React.useState(document.documentElement.classList.contains('dark'));
- const [editingMessage, setEditingMessage] = React.useState<{convId: string, msg: ChatMessage} | null>(null);
- React.useEffect(() => {
- const observer = new MutationObserver(() => setIsDarkMode(document.documentElement.classList.contains('dark')));
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
- return () => observer.disconnect();
- }, []);
- const chartColors = {
- grid: isDarkMode ? '#374151' : '#e5e7eb',
- text: isDarkMode ? '#9CA3AF' : '#6b7280',
- tooltipBg: isDarkMode ? '#1F2937' : '#FFFFFF',
- tooltipBorder: isDarkMode ? '#4B5563' : '#d1d5db',
- bar1: isDarkMode ? '#10b981' : '#10b981',
- bar2: isDarkMode ? '#3b82f6' : '#3b82f6',
- };
- const filteredConversations = React.useMemo(() => {
- const dateLimit = subDays(new Date(), parseInt(dateFilter, 10));
- return conversations.filter(c => {
- const conversationDate = new Date(c.timestamp);
- if (conversationDate < dateLimit) return false;
- if (statusFilter !== 'all' && c.status !== statusFilter) return false;
- if (searchTerm && !c.interactions.some(i => i.text.toLowerCase().includes(searchTerm.toLowerCase()))) return false;
- return true;
- });
- }, [conversations, dateFilter, statusFilter, searchTerm]);
- const analyticsSummary = React.useMemo(() => {
- const total = filteredConversations.length;
- if (total === 0) return { total: 0, resolved: 0, resolutionRate: '0%', avgInteractions: 0, avgResponseTime: 'N/A', conversationsByDay: [], commonTopics: [], visitorBreakdown: [] };
- const resolved = filteredConversations.filter(c => c.status === 'resolved').length;
- const resolutionRate = `${((resolved / total) * 100).toFixed(0)}%`;
- const totalInteractions = filteredConversations.reduce((sum, c) => sum + c.interactions.length, 0);
- const avgInteractions = parseFloat((totalInteractions / total).toFixed(1));
- const responseTimes = filteredConversations.map(c => c.firstResponseTime || 0).filter(t => t > 0);
- const avgResponseTimeSec = responseTimes.length > 0 ? responseTimes.reduce((sum, t) => sum + t, 0) / responseTimes.length : 0;
- const avgResponseTime = avgResponseTimeSec > 0 ? `${Math.round(avgResponseTimeSec)}s` : 'N/A';
-
- const daysInRange = eachDayOfInterval({ start: subDays(new Date(), parseInt(dateFilter) - 1), end: new Date() });
- const conversationsByDay = daysInRange.map(day => ({
- date: format(day, 'MMM d', { locale: dateLocale }),
- conversations: filteredConversations.filter(c => format(startOfDay(new Date(c.timestamp)), 'MMM d', { locale: dateLocale }) === format(day, 'MMM d', { locale: dateLocale })).length
- }));
- const wordCounts: { [key: string]: number } = {};
- filteredConversations.forEach(c => c.interactions.forEach(i => { if(i.sender === 'user') { const words = i.text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/); words.forEach(word => { if (word && !STOP_WORDS.has(word)) { wordCounts[word] = (wordCounts[word] || 0) + 1; } }); }}));
- const commonTopics = Object.entries(wordCounts).sort(([,a],[,b]) => b - a).slice(0, 7).map(([name, count]) => ({ name, count }));
- const visitorStats: {[key: string]: { visits: number, conversations: number}} = {};
- filteredConversations.forEach(c => {
- if(!visitorStats[c.visitorId]) visitorStats[c.visitorId] = { visits: c.visitCount, conversations: 0 };
- visitorStats[c.visitorId].conversations++;
- });
- const visitorBreakdown = Object.entries(visitorStats).map(([id, stats]) => ({ id, ...stats })).sort((a,b) => b.conversations - a.conversations);
- return { total, resolved, resolutionRate, avgInteractions, avgResponseTime, conversationsByDay, commonTopics, visitorBreakdown };
- }, [filteredConversations, dateFilter, dateLocale]);
- const handleMarkAsResolved = (id: string) => onUpdateConversations(conversations.map(c => (c.id === id ? { ...c, status: 'resolved' } : c)));
- const handleSaveResponse = (newText: string) => {
- if(!editingMessage) return;
- const { convId, msg } = editingMessage;
- const updatedConversations = conversations.map(c => {
- if (c.id === convId) {
- return { ...c, interactions: c.interactions.map(i => i.id === msg.id ? { ...i, text: newText } : i) };
- }
- return c;
- });
- onUpdateConversations(updatedConversations);
- setEditingMessage(null);
- alert(t('analytics.interactions.update_success'));
- };
-
- return (
- <div className="p-6 space-y-6">
- {editingMessage && <EditResponseModal message={editingMessage.msg} onClose={() => setEditingMessage(null)} onSave={handleSaveResponse} />}
- <h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('analytics.interactions.title')}</h2>
-
- <div className="bg-white dark:bg-gray-800 p-4 rounded-lg flex flex-wrap items-center gap-4">
- <div className="relative flex-1 min-w-[200px]">
- <input type="text" placeholder={t('analytics.interactions.search_placeholder')} value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="w-full bg-gray-100 dark:bg-gray-700 p-2 pl-10 rounded-md border border-gray-300 dark:border-gray-600 focus:ring-brand-primary" />
- <Icon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-400"><path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></Icon>
- </div>
- <div className="flex items-center gap-2">
- <label className="text-sm text-gray-500 dark:text-gray-400">{t('analytics.interactions.date_label')}</label>
- <select value={dateFilter} onChange={e => setDateFilter(e.target.value)} className="bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600 focus:ring-brand-primary">
- <option value="7">{t('analytics.interactions.last_7_days')}</option>
- <option value="28">{t('analytics.interactions.last_28_days')}</option>
- </select>
- </div>
- <div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 p-1 rounded-lg">
- {(['all', 'open', 'resolved'] as const).map(status => (
- <button key={status} onClick={() => setStatusFilter(status)} className={`px-3 py-1 text-sm rounded-md capitalize ${statusFilter === status ? 'bg-brand-primary text-white' : 'hover:bg-gray-300 dark:hover:bg-gray-600'}`}>
- {t(status)}
- </button>
- ))}
- </div>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.total_conversations')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.total}</p></div>
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.resolution_rate')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.resolutionRate}</p></div>
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.avg_interactions')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.avgInteractions}</p></div>
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg"><h3 className="text-gray-500 dark:text-gray-400 text-sm font-medium">{t('analytics.interactions.avg_first_response')}</h3><p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">{analyticsSummary.avgResponseTime}</p></div>
- </div>
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg">
- <h3 className="font-semibold text-gray-900 dark:text-white mb-4">{t('analytics.interactions.visitor_breakdown')}</h3>
- <div className="h-[300px] overflow-y-auto">
- <table className="min-w-full">
- <thead className="sticky top-0 bg-white dark:bg-gray-800"><tr><th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase py-2">{t('analytics.interactions.visitor')}</th><th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase py-2">{t('analytics.interactions.conversations')}</th></tr></thead>
- <tbody>{analyticsSummary.visitorBreakdown.map(v => <tr key={v.id} className="border-t border-gray-200 dark:border-gray-700"><td className="py-2 font-semibold text-sm text-brand-primary">{v.id}</td><td className="py-2 text-sm">{v.conversations}</td></tr>)}</tbody>
- </table>
- </div>
- </div>
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg">
- <h3 className="font-semibold text-gray-900 dark:text-white mb-4">{t('analytics.interactions.common_topics')}</h3>
- <ResponsiveContainer width="100%" height={300}>
- <BarChart data={analyticsSummary.commonTopics} layout="vertical" margin={{ top: 5, right: 20, left: 30, bottom: 5 }}>
- <CartesianGrid strokeDasharray="3 3" stroke={chartColors.grid} horizontal={false} />
- <XAxis type="number" stroke={chartColors.text} fontSize={12} />
- <YAxis type="category" dataKey="name" stroke={chartColors.text} fontSize={12} width={80} />
- <Tooltip contentStyle={{ backgroundColor: chartColors.tooltipBg, border: `1px solid ${chartColors.tooltipBorder}` }} cursor={{fill: isDarkMode ? '#374151' : '#f3f4f6'}}/>
- <Bar dataKey="count" fill={chartColors.bar2} />
- </BarChart>
- </ResponsiveContainer>
- </div>
- </div>
- <div className="bg-white dark:bg-gray-800 rounded-lg">
- <div className="p-4 space-y-4">
- {filteredConversations.length > 0 ? filteredConversations.map(conv => (
- <div key={conv.id} className="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
- <div className="flex flex-wrap justify-between items-center mb-3 pb-3 border-b border-gray-200 dark:border-gray-700 gap-2">
- <p className="text-sm font-semibold text-gray-900 dark:text-white">{t('analytics.interactions.visitor')} <span className="text-brand-primary">{conv.visitorId}</span></p>
- <div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
- <span>{new Date(conv.timestamp).toLocaleString(language)}</span>
- <span>{conv.visitCount} visits</span>
- <span className={`px-2 py-1 rounded-full text-xs font-semibold ${conv.status === 'resolved' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>{conv.status}</span>
- {conv.status === 'open' && ( <button onClick={() => handleMarkAsResolved(conv.id)} className="px-2 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white text-xs">{t('analytics.interactions.mark_resolved')}</button> )}
- </div>
- </div>
- <div className="space-y-3 max-h-48 overflow-y-auto pr-2">
- {conv.interactions.map(msg => (
- <div key={msg.id} className={`group flex items-start gap-3 text-sm ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
- {msg.sender === 'ai' && <div className="w-6 h-6 rounded-full bg-brand-primary flex items-center justify-center flex-shrink-0 text-xs">🤖</div>}
- <div className={`max-w-xl p-2 rounded-lg ${msg.sender === 'user' ? 'bg-blue-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200'}`}>{msg.text}</div>
- {msg.sender === 'ai' && <button onClick={() => setEditingMessage({convId: conv.id, msg: msg})} className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-gray-700 dark:hover:text-white"><Icon className="h-4 w-4"><path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L16.732 3.732z" /></Icon></button>}
- {msg.sender === 'user' && <div className="w-6 h-6 rounded-full bg-gray-400 dark:bg-gray-600 flex items-center justify-center flex-shrink-0 text-xs">👤</div>}
- </div>
- ))}
- </div>
- </div>
- )) : (
- <div className="text-center py-12 text-gray-400 dark:text-gray-500">
- <p>{t('analytics.interactions.no_conversations')}</p>
- </div>
- )}
- </div>
- </div>
- </div>
- );
- };
- export default InteractionAnalytics;
|