Performance improvements: - Validation speed: 59.71s → 10.96s (5.5x improvement) - Removed 50+ console.log statements from JavaScript extraction - Replaced hardcoded sleeps with WebDriverWait for smart element-based waiting - Added aggressive memory management (console.clear, GC, image unloading every 20 scrolls) Scraping improvements: - Increased idle detection from 6 to 12 consecutive idle scrolls for completeness - Added real-time progress updates every 5 scrolls with percentage calculation - Added crash recovery to extract partial reviews if Chrome crashes - Removed artificial 200-review limit to scrape ALL reviews Timestamp tracking: - Added updated_at field separate from started_at for progress tracking - Frontend now shows both "Started" (fixed) and "Last Update" (dynamic) Robustness improvements: - Added 5 fallback CSS selectors to handle different Google Maps page structures - Now tries: div.jftiEf.fontBodyMedium, div.jftiEf, div[data-review-id], etc. - Automatic selector detection logs which selector works for debugging Test results: - Successfully scraped 550 reviews in 150.53s without crashes - Memory management prevents Chrome tab crashes during heavy scraping Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
704 lines
30 KiB
TypeScript
704 lines
30 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
getFilteredRowModel,
|
|
getSortedRowModel,
|
|
getPaginationRowModel,
|
|
ColumnDef,
|
|
flexRender,
|
|
SortingState,
|
|
ColumnFiltersState,
|
|
} from '@tanstack/react-table';
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line } from 'recharts';
|
|
import { Star, TrendingUp, Image, FileText, MessageSquare, Calendar, ArrowUpDown, ArrowUp, ArrowDown, Search, Download, Filter, AlertTriangle, ThumbsUp, ThumbsDown } from 'lucide-react';
|
|
import { Review, calculateReviewStats, getSentimentLabel, getSentimentColor, DateRange, filterReviewsByDateRange, calculateTimelineData } from '@/lib/analytics';
|
|
|
|
interface ReviewAnalyticsProps {
|
|
reviews: Review[];
|
|
businessName?: string;
|
|
}
|
|
|
|
export default function ReviewAnalytics({ reviews, businessName }: ReviewAnalyticsProps) {
|
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'date', desc: true }]); // Default: newest first
|
|
const [columnFilters, setColumnFiltersState] = useState<ColumnFiltersState>([]);
|
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
const [selectedRatings, setSelectedRatings] = useState<number[]>([1, 2, 3, 4, 5]);
|
|
const [selectedSentiments, setSelectedSentiments] = useState<('positive' | 'neutral' | 'negative')[]>(['positive', 'neutral', 'negative']);
|
|
const [dateRange, setDateRange] = useState<DateRange>('all');
|
|
|
|
// Filter reviews by date range
|
|
const dateFilteredReviews = useMemo(() => {
|
|
return filterReviewsByDateRange(reviews, dateRange);
|
|
}, [reviews, dateRange]);
|
|
|
|
// Calculate statistics on date-filtered reviews
|
|
const stats = useMemo(() => calculateReviewStats(dateFilteredReviews), [dateFilteredReviews]);
|
|
|
|
// Calculate timeline data for chart
|
|
const timelineData = useMemo(() => calculateTimelineData(dateFilteredReviews), [dateFilteredReviews]);
|
|
|
|
// Filter reviews by selected ratings and sentiments (for table)
|
|
const filteredReviews = useMemo(() => {
|
|
return dateFilteredReviews.filter(r => {
|
|
const matchesRating = selectedRatings.includes(r.rating);
|
|
const sentiment = getSentimentLabel(r.rating);
|
|
const matchesSentiment = selectedSentiments.includes(sentiment);
|
|
const matchesSearch = !globalFilter ||
|
|
r.author.toLowerCase().includes(globalFilter.toLowerCase()) ||
|
|
r.text?.toLowerCase().includes(globalFilter.toLowerCase()) ||
|
|
r.date_text.toLowerCase().includes(globalFilter.toLowerCase());
|
|
|
|
return matchesRating && matchesSentiment && matchesSearch;
|
|
});
|
|
}, [dateFilteredReviews, selectedRatings, selectedSentiments, globalFilter]);
|
|
|
|
const toggleRating = (rating: number) => {
|
|
setSelectedRatings(prev =>
|
|
prev.includes(rating) ? prev.filter(r => r !== rating) : [...prev, rating]
|
|
);
|
|
};
|
|
|
|
const toggleSentiment = (sentiment: 'positive' | 'neutral' | 'negative') => {
|
|
setSelectedSentiments(prev =>
|
|
prev.includes(sentiment) ? prev.filter(s => s !== sentiment) : [...prev, sentiment]
|
|
);
|
|
};
|
|
|
|
const clearAllFilters = () => {
|
|
setDateRange('all');
|
|
setSelectedRatings([1, 2, 3, 4, 5]);
|
|
setSelectedSentiments(['positive', 'neutral', 'negative']);
|
|
setGlobalFilter('');
|
|
};
|
|
|
|
const hasActiveFilters = dateRange !== 'all' ||
|
|
selectedRatings.length < 5 ||
|
|
selectedSentiments.length < 3 ||
|
|
globalFilter !== '';
|
|
|
|
const exportFilteredData = () => {
|
|
const dataStr = JSON.stringify(filteredReviews, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `reviews-filtered-${dateRange}-${new Date().toISOString().split('T')[0]}.json`;
|
|
link.click();
|
|
};
|
|
|
|
// Chart colors
|
|
const COLORS = {
|
|
positive: '#16a34a',
|
|
neutral: '#ca8a04',
|
|
negative: '#dc2626',
|
|
};
|
|
|
|
// Table columns
|
|
const columns = useMemo<ColumnDef<Review>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: 'author',
|
|
header: ({ column }) => {
|
|
return (
|
|
<button
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
|
>
|
|
Author
|
|
{column.getIsSorted() === 'asc' ? <ArrowUp className="w-4 h-4" /> : column.getIsSorted() === 'desc' ? <ArrowDown className="w-4 h-4" /> : <ArrowUpDown className="w-4 h-4 opacity-50" />}
|
|
</button>
|
|
);
|
|
},
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center gap-2">
|
|
{row.original.avatar_url && (
|
|
<img src={row.original.avatar_url} alt={row.original.author} className="w-8 h-8 rounded-full" />
|
|
)}
|
|
<span className="font-medium text-gray-900">{row.original.author}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'rating',
|
|
header: ({ column }) => {
|
|
return (
|
|
<button
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
|
>
|
|
Rating
|
|
{column.getIsSorted() === 'asc' ? <ArrowUp className="w-4 h-4" /> : column.getIsSorted() === 'desc' ? <ArrowDown className="w-4 h-4" /> : <ArrowUpDown className="w-4 h-4 opacity-50" />}
|
|
</button>
|
|
);
|
|
},
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center gap-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className={`w-4 h-4 ${i < row.original.rating ? 'text-yellow-500 fill-yellow-500' : 'text-gray-300'}`}
|
|
/>
|
|
))}
|
|
<span className="ml-2 font-bold text-gray-900">{row.original.rating}</span>
|
|
</div>
|
|
),
|
|
filterFn: (row, id, value) => {
|
|
return value.includes(row.getValue(id));
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'centerDate',
|
|
id: 'date',
|
|
header: ({ column }) => {
|
|
return (
|
|
<button
|
|
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
className="flex items-center gap-2 hover:text-blue-700 font-semibold"
|
|
>
|
|
Date
|
|
{column.getIsSorted() === 'asc' ? <ArrowUp className="w-4 h-4" /> : column.getIsSorted() === 'desc' ? <ArrowDown className="w-4 h-4" /> : <ArrowUpDown className="w-4 h-4 opacity-50" />}
|
|
</button>
|
|
);
|
|
},
|
|
sortingFn: (rowA, rowB) => {
|
|
const dateA = rowA.original.centerDate?.getTime() || 0;
|
|
const dateB = rowB.original.centerDate?.getTime() || 0;
|
|
return dateA - dateB;
|
|
},
|
|
cell: ({ row }) => {
|
|
const formatDate = (date: Date) => {
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
};
|
|
|
|
const getUncertaintyDays = (minDate: Date, maxDate: Date) => {
|
|
const diffMs = Math.abs(maxDate.getTime() - minDate.getTime());
|
|
return Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="text-gray-900 font-medium">{row.original.date_text}</div>
|
|
{row.original.minDate && row.original.maxDate && row.original.centerDate && (
|
|
<div className="text-xs text-gray-500 space-y-0.5">
|
|
<div>Range: {formatDate(row.original.maxDate)} - {formatDate(row.original.minDate)}</div>
|
|
<div className="text-purple-700 font-semibold">
|
|
Center: {formatDate(row.original.centerDate)}
|
|
</div>
|
|
<div className="text-blue-600">
|
|
±{getUncertaintyDays(row.original.minDate, row.original.maxDate)} days uncertainty
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: 'text',
|
|
header: 'Review',
|
|
cell: ({ row }) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const text = row.original.text || 'No review text';
|
|
const sentiment = getSentimentLabel(row.original.rating);
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<div className={`inline-block px-2 py-1 rounded-md text-xs font-semibold mb-2 border ${getSentimentColor(sentiment)}`}>
|
|
{sentiment.toUpperCase()}
|
|
</div>
|
|
<p className={`text-gray-800 ${!expanded && 'line-clamp-2'}`}>
|
|
{text}
|
|
</p>
|
|
{text.length > 100 && (
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="text-blue-700 hover:text-blue-800 text-sm font-semibold mt-1"
|
|
>
|
|
{expanded ? 'Show less' : 'Show more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: filteredReviews,
|
|
columns,
|
|
state: {
|
|
sorting,
|
|
},
|
|
onSortingChange: setSorting,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getPaginationRowModel: getPaginationRowModel(),
|
|
initialState: {
|
|
pagination: {
|
|
pageSize: 10,
|
|
},
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-3xl font-bold text-gray-900">
|
|
{businessName ? `${businessName} - Analytics` : 'Review Analytics'}
|
|
</h2>
|
|
<p className="text-gray-600 mt-1">Comprehensive insights from {reviews.length} total reviews</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Enhanced Filters */}
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-5 shadow-sm space-y-4">
|
|
{/* Time Period Filter */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<Filter className="w-5 h-5 text-gray-700" />
|
|
<span className="font-semibold text-gray-900">Time Period:</span>
|
|
{(['week', 'month', 'year', 'all'] as DateRange[]).map((range) => (
|
|
<button
|
|
key={range}
|
|
onClick={() => setDateRange(range)}
|
|
className={`px-4 py-2 rounded-lg font-semibold transition-all border-2 ${
|
|
dateRange === range
|
|
? 'bg-blue-600 text-white border-blue-700 shadow-md'
|
|
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
{range === 'week' ? 'Last Week' : range === 'month' ? 'Last Month' : range === 'year' ? 'Last Year' : 'All Time'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sentiment Filter */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<TrendingUp className="w-5 h-5 text-gray-700" />
|
|
<span className="font-semibold text-gray-900">Sentiment:</span>
|
|
{(['positive', 'neutral', 'negative'] as const).map((sentiment) => (
|
|
<button
|
|
key={sentiment}
|
|
onClick={() => toggleSentiment(sentiment)}
|
|
className={`px-4 py-2 rounded-lg font-semibold transition-all border-2 ${
|
|
selectedSentiments.includes(sentiment)
|
|
? sentiment === 'positive' ? 'bg-green-600 text-white border-green-700 shadow-md'
|
|
: sentiment === 'neutral' ? 'bg-yellow-600 text-white border-yellow-700 shadow-md'
|
|
: 'bg-red-600 text-white border-red-700 shadow-md'
|
|
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-400 hover:bg-blue-50'
|
|
}`}
|
|
>
|
|
{sentiment === 'positive' ? '😊 Positive (4-5★)' : sentiment === 'neutral' ? '😐 Neutral (3★)' : '😞 Negative (1-2★)'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filter Summary */}
|
|
<div className="flex items-center justify-between pt-2 border-t-2 border-gray-200">
|
|
<span className="text-sm font-medium text-gray-600">
|
|
Showing {filteredReviews.length} of {reviews.length} reviews
|
|
{hasActiveFilters && <span className="text-blue-700 ml-1">(filtered)</span>}
|
|
</span>
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={clearAllFilters}
|
|
className="px-3 py-1.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 font-semibold border-2 border-gray-300 text-sm"
|
|
>
|
|
Clear All Filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{/* Average Rating */}
|
|
<div className="bg-gradient-to-br from-yellow-100 to-yellow-200 border-2 border-yellow-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Star className="w-5 h-5 text-yellow-700" />
|
|
<span className="text-sm font-bold text-yellow-900">Avg Rating</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-yellow-900">{stats.averageRating.toFixed(1)}★</div>
|
|
<div className="text-xs text-yellow-800 mt-1 font-medium">
|
|
{stats.totalReviews} total reviews
|
|
</div>
|
|
</div>
|
|
|
|
{/* Positive Reviews */}
|
|
<div className="bg-gradient-to-br from-green-100 to-green-200 border-2 border-green-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow cursor-pointer" onClick={() => { setSelectedSentiments(['positive']); setDateRange('all'); }}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<ThumbsUp className="w-5 h-5 text-green-700" />
|
|
<span className="text-sm font-bold text-green-900">Positive</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-green-900">{stats.sentimentBreakdown.positive}</div>
|
|
<div className="text-xs text-green-800 mt-1 font-medium">
|
|
{stats.sentimentScore.toFixed(0)}% positive (4-5★)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Neutral Reviews */}
|
|
<div className="bg-gradient-to-br from-yellow-50 to-yellow-100 border-2 border-yellow-300 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow cursor-pointer" onClick={() => { setSelectedSentiments(['neutral']); setDateRange('all'); }}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="w-5 h-5 text-yellow-700" />
|
|
<span className="text-sm font-bold text-yellow-800">Neutral</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-yellow-800">{stats.sentimentBreakdown.neutral}</div>
|
|
<div className="text-xs text-yellow-700 mt-1 font-medium">
|
|
{((stats.sentimentBreakdown.neutral / stats.totalReviews) * 100).toFixed(0)}% neutral (3★)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Negative Reviews - Alert */}
|
|
<div className="bg-gradient-to-br from-red-100 to-red-200 border-2 border-red-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow cursor-pointer" onClick={() => { setSelectedSentiments(['negative']); setDateRange('all'); }}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<AlertTriangle className="w-5 h-5 text-red-700" />
|
|
<span className="text-sm font-bold text-red-900">Negative</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-red-900">{stats.negativeReviews}</div>
|
|
<div className="text-xs text-red-800 mt-1 font-medium">
|
|
{((stats.negativeReviews / stats.totalReviews) * 100).toFixed(0)}% negative (1-2★)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity */}
|
|
<div className="bg-gradient-to-br from-blue-100 to-blue-200 border-2 border-blue-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow cursor-pointer" onClick={() => setDateRange('month')}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-5 h-5 text-blue-700" />
|
|
<span className="text-sm font-bold text-blue-900">Recent</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-blue-900">{stats.recentReviews}</div>
|
|
<div className="text-xs text-blue-800 mt-1 font-medium">last 30 days</div>
|
|
</div>
|
|
|
|
{/* Review Length */}
|
|
<div className="bg-gradient-to-br from-purple-100 to-purple-200 border-2 border-purple-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-purple-700" />
|
|
<span className="text-sm font-bold text-purple-900">Avg Length</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-purple-900">{stats.avgReviewLength}</div>
|
|
<div className="text-xs text-purple-800 mt-1 font-medium">words per review</div>
|
|
</div>
|
|
|
|
{/* Photos */}
|
|
<div className="bg-gradient-to-br from-pink-100 to-pink-200 border-2 border-pink-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Image className="w-5 h-5 text-pink-700" />
|
|
<span className="text-sm font-bold text-pink-900">With Photos</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-pink-900">{stats.photoCount}</div>
|
|
<div className="text-xs text-pink-800 mt-1 font-medium">
|
|
{((stats.photoCount / stats.totalReviews) * 100).toFixed(0)}% have avatars
|
|
</div>
|
|
</div>
|
|
|
|
{/* Total Reviews */}
|
|
<div className="bg-gradient-to-br from-indigo-100 to-indigo-200 border-2 border-indigo-400 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="w-5 h-5 text-indigo-700" />
|
|
<span className="text-sm font-bold text-indigo-900">Total</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-3xl font-bold text-indigo-900">{stats.totalReviews}</div>
|
|
<div className="text-xs text-indigo-800 mt-1 font-medium">all time</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Rating Timeline with Rolling Average */}
|
|
{timelineData.length > 0 && (
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-6 shadow-md">
|
|
<h3 className="text-xl font-bold mb-4 text-gray-900">Rating Trend Over Time</h3>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<LineChart data={timelineData}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
<XAxis
|
|
dataKey="date"
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<YAxis
|
|
domain={[0, 5]}
|
|
ticks={[0, 1, 2, 3, 4, 5]}
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#ffffff',
|
|
border: '2px solid #3b82f6',
|
|
borderRadius: '8px',
|
|
fontWeight: 600
|
|
}}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="rating"
|
|
stroke="#94a3b8"
|
|
strokeWidth={2}
|
|
name="Monthly Avg"
|
|
dot={{ fill: '#64748b', r: 4 }}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="rollingAvg"
|
|
stroke="#3b82f6"
|
|
strokeWidth={3}
|
|
name="3-Month Rolling Avg"
|
|
dot={{ fill: '#2563eb', r: 5 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{/* Charts Grid */}
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
{/* Rating Distribution - Interactive */}
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-6 shadow-md">
|
|
<h3 className="text-lg font-bold mb-4 text-gray-900">
|
|
Rating Distribution
|
|
<span className="text-xs font-normal text-gray-500 ml-2">(click to filter)</span>
|
|
</h3>
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart
|
|
data={stats.ratingDistribution}
|
|
onClick={(data) => {
|
|
if (data && data.activePayload && data.activePayload[0]) {
|
|
const rating = data.activePayload[0].payload.rating;
|
|
setSelectedRatings([rating]);
|
|
setSelectedSentiments(['positive', 'neutral', 'negative']);
|
|
}
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
<XAxis
|
|
dataKey="rating"
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<YAxis
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#ffffff',
|
|
border: '2px solid #3b82f6',
|
|
borderRadius: '8px',
|
|
fontWeight: 600
|
|
}}
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-white border-2 border-blue-600 rounded-lg p-2 shadow-lg">
|
|
<p className="font-bold text-gray-900">{payload[0].payload.rating}★</p>
|
|
<p className="text-sm text-gray-600">{payload[0].value} reviews ({payload[0].payload.percentage.toFixed(1)}%)</p>
|
|
<p className="text-xs text-blue-600 mt-1">Click to filter</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<Bar dataKey="count" fill="#3b82f6" radius={[8, 8, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Sentiment Breakdown - Interactive */}
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-6 shadow-md">
|
|
<h3 className="text-lg font-bold mb-4 text-gray-900">
|
|
Sentiment Breakdown
|
|
<span className="text-xs font-normal text-gray-500 ml-2">(click to filter)</span>
|
|
</h3>
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<PieChart>
|
|
<Pie
|
|
data={[
|
|
{ name: 'Positive', value: stats.sentimentBreakdown.positive, sentiment: 'positive' },
|
|
{ name: 'Neutral', value: stats.sentimentBreakdown.neutral, sentiment: 'neutral' },
|
|
{ name: 'Negative', value: stats.sentimentBreakdown.negative, sentiment: 'negative' },
|
|
]}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
outerRadius={80}
|
|
fill="#8884d8"
|
|
dataKey="value"
|
|
style={{ fontWeight: 700, fontSize: '13px', cursor: 'pointer' }}
|
|
onClick={(data) => {
|
|
if (data && data.sentiment) {
|
|
setSelectedSentiments([data.sentiment as 'positive' | 'neutral' | 'negative']);
|
|
setSelectedRatings([1, 2, 3, 4, 5]);
|
|
}
|
|
}}
|
|
>
|
|
<Cell fill={COLORS.positive} />
|
|
<Cell fill={COLORS.neutral} />
|
|
<Cell fill={COLORS.negative} />
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#ffffff',
|
|
border: '2px solid #3b82f6',
|
|
borderRadius: '8px',
|
|
fontWeight: 600
|
|
}}
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-white border-2 border-blue-600 rounded-lg p-2 shadow-lg">
|
|
<p className="font-bold text-gray-900">{payload[0].name}</p>
|
|
<p className="text-sm text-gray-600">{payload[0].value} reviews</p>
|
|
<p className="text-xs text-blue-600 mt-1">Click to filter</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Top Keywords */}
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-6 shadow-md">
|
|
<h3 className="text-lg font-bold mb-4 text-gray-900">Top Keywords</h3>
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart data={stats.topKeywords} layout="vertical">
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
<XAxis
|
|
type="number"
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="word"
|
|
width={80}
|
|
tick={{ fill: '#374151', fontWeight: 600 }}
|
|
tickLine={{ stroke: '#9ca3af' }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#ffffff',
|
|
border: '2px solid #3b82f6',
|
|
borderRadius: '8px',
|
|
fontWeight: 600
|
|
}}
|
|
/>
|
|
<Bar dataKey="count" fill="#8b5cf6" radius={[0, 8, 8, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reviews Table */}
|
|
<div className="bg-white border-2 border-gray-300 rounded-xl p-6 shadow-md">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-xl font-bold text-gray-900">Review Details</h3>
|
|
<button
|
|
onClick={exportFilteredData}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold shadow-md border-2 border-green-700"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Export Filtered Data
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="mb-6">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
value={globalFilter}
|
|
onChange={e => setGlobalFilter(e.target.value)}
|
|
placeholder="Search by author, review text, or date..."
|
|
className="w-full pl-10 pr-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-medium"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-x-auto border-2 border-gray-300 rounded-lg">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
|
{table.getHeaderGroups().map(headerGroup => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map(header => (
|
|
<th key={header.id} className="px-6 py-4 text-left text-gray-900">
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody className="divide-y-2 divide-gray-200">
|
|
{table.getRowModel().rows.map(row => (
|
|
<tr key={row.id} className="hover:bg-gray-50">
|
|
{row.getVisibleCells().map(cell => (
|
|
<td key={cell.id} className="px-6 py-4">
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex items-center justify-between mt-6">
|
|
<div className="text-sm text-gray-700 font-medium">
|
|
Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{' '}
|
|
{Math.min((table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, filteredReviews.length)} of{' '}
|
|
{filteredReviews.length} reviews
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => table.previousPage()}
|
|
disabled={!table.getCanPreviousPage()}
|
|
className="px-4 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => table.nextPage()}
|
|
disabled={!table.getCanNextPage()}
|
|
className="px-4 py-2 border-2 border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 font-semibold text-gray-900"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|