Files
whyrating-engine-legacy/web/components/ReviewAnalytics.tsx
Alejandro Gutiérrez 9e1bcde981 Wave 2: Migrate scraper to StructuredLogger, add crash detection & topic tags
- Task #2: Migrate scraper_clean.py to use StructuredLogger with categories
  (37 log calls with metrics across browser/scraper/network/system)
- Task #4: Add crash_reports table schema and database methods
  (save_crash_report, get_crash_report, get_crash_stats)
- Task #9: Implement crash detection wrapper with metrics sampling
  (get_chrome_memory, get_dom_node_count, classify_crash)
- Task #17: Add topic tags to frontend ReviewAnalytics
  (topic filter UI, tags on cards, topics in modal)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 12:17:23 +00:00

1751 lines
80 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, Brush, ReferenceArea, ComposedChart, Area, Legend } from 'recharts';
import { Star, TrendingUp, Image, FileText, MessageSquare, Calendar, ArrowUpDown, ArrowUp, ArrowDown, Search, Download, Filter, AlertTriangle, ThumbsUp, ThumbsDown, X, ExternalLink, MessageCircleReply, CheckCircle2, XCircle } from 'lucide-react';
import { Review, OwnerResponse, calculateReviewStats, getSentimentLabel, getSentimentColor, DateRange, filterReviewsByDateRange, calculateTimelineData } from '@/lib/analytics';
interface ReviewWithNew extends Review {
is_new?: boolean;
owner_response?: OwnerResponse | null;
photo_urls?: string[] | null;
topics?: string[];
}
interface ReviewTopic {
topic: string;
count: number;
}
interface ReviewAnalyticsProps {
reviews: ReviewWithNew[];
businessName?: string;
businessUrl?: string;
newCount?: number;
businessCategory?: string;
reviewTopics?: ReviewTopic[];
}
export default function ReviewAnalytics({ reviews, businessName, businessUrl, newCount, businessCategory, reviewTopics }: 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 [selectedResponseStatus, setSelectedResponseStatus] = useState<('answered' | 'not_answered')[]>(['answered', 'not_answered']);
const [dateRange, setDateRange] = useState<DateRange>('all');
const [selectedReview, setSelectedReview] = useState<ReviewWithNew | null>(null);
const [showOnlyNew, setShowOnlyNew] = useState(false);
const [brushRange, setBrushRange] = useState<{ startIndex: number; endIndex: number } | null>(null);
const [selectedTopics, setSelectedTopics] = useState<string[]>([]);
// Check if we have comparison data
const hasComparisonData = reviews.some(r => r.is_new !== undefined);
// Filter reviews by date range (preserving is_new flag)
const dateFilteredReviews = useMemo(() => {
const filtered = filterReviewsByDateRange(reviews as Review[], dateRange);
// Re-attach is_new flag from original reviews
return filtered.map(r => {
const original = reviews.find(orig => orig.review_id === r.review_id);
return { ...r, is_new: original?.is_new } as ReviewWithNew;
});
}, [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]);
// Calculate available topics with counts
const availableTopics = useMemo(() => {
const topicCounts = new Map<string, number>();
reviews.forEach(r => {
r.topics?.forEach(t => {
topicCounts.set(t, (topicCounts.get(t) || 0) + 1);
});
});
return Array.from(topicCounts.entries())
.map(([topic, count]) => ({ topic, count }))
.sort((a, b) => b.count - a.count);
}, [reviews]);
// Check if brush covers the full range (no filtering needed)
const isFullRange = useMemo(() => {
if (!brushRange || timelineData.length === 0) return true;
return brushRange.startIndex === 0 && brushRange.endIndex === timelineData.length - 1;
}, [brushRange, timelineData]);
// Get the date range from brush selection (null if full range = no filter)
const brushDateRange = useMemo(() => {
if (!brushRange || timelineData.length === 0 || isFullRange) return null;
const startDate = timelineData[brushRange.startIndex]?.date;
const endDate = timelineData[brushRange.endIndex]?.date;
if (!startDate || !endDate) return null;
return { startDate, endDate };
}, [brushRange, timelineData, isFullRange]);
// Helper to set brush range based on time period
const setTimeRangeFilter = (range: '3m' | '6m' | '1y' | 'all') => {
if (timelineData.length === 0) return;
if (range === 'all') {
// Set to full range (visually shows full selection)
setBrushRange({ startIndex: 0, endIndex: timelineData.length - 1 });
return;
}
const now = new Date();
let cutoffDate: Date;
switch (range) {
case '3m':
cutoffDate = new Date(now.getFullYear(), now.getMonth() - 3, 1);
break;
case '6m':
cutoffDate = new Date(now.getFullYear(), now.getMonth() - 6, 1);
break;
case '1y':
cutoffDate = new Date(now.getFullYear() - 1, now.getMonth(), 1);
break;
}
const monthsMap: Record<string, number> = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
const parseMonthYear = (d: string) => {
const [month, year] = d.split(' ');
return new Date(parseInt(year), monthsMap[month] || 0, 1);
};
// Find the first index where date >= cutoffDate
let startIndex = timelineData.findIndex(d => parseMonthYear(d.date) >= cutoffDate);
if (startIndex === -1) startIndex = 0;
// End index is the last item
const endIndex = timelineData.length - 1;
setBrushRange({ startIndex, endIndex });
};
// Determine which time range button is active based on brush selection
const activeTimeRange = useMemo((): '3m' | '6m' | '1y' | 'all' => {
if (!brushRange || timelineData.length === 0) return 'all';
const now = new Date();
const monthsMap: Record<string, number> = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
const parseMonthYear = (d: string) => {
const [month, year] = d.split(' ');
return new Date(parseInt(year), monthsMap[month] || 0, 1);
};
const startDate = parseMonthYear(timelineData[brushRange.startIndex]?.date || '');
const endIndex = brushRange.endIndex;
const isAtEnd = endIndex === timelineData.length - 1;
if (!isAtEnd) return 'all'; // Custom range
// Check which preset matches
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1);
const sixMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 6, 1);
const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), 1);
// Allow 1 month tolerance for matching
const isClose = (a: Date, b: Date) => Math.abs(a.getTime() - b.getTime()) < 32 * 24 * 60 * 60 * 1000;
if (isClose(startDate, threeMonthsAgo)) return '3m';
if (isClose(startDate, sixMonthsAgo)) return '6m';
if (isClose(startDate, oneYearAgo)) return '1y';
return 'all';
}, [brushRange, timelineData]);
// Filter reviews by selected ratings, sentiments, response status, new status, topics, and brush range (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()) ||
r.owner_response?.text?.toLowerCase().includes(globalFilter.toLowerCase());
const matchesNew = !showOnlyNew || r.is_new === true;
// Filter by response status
const hasResponse = !!r.owner_response?.text;
const matchesResponseStatus =
(hasResponse && selectedResponseStatus.includes('answered')) ||
(!hasResponse && selectedResponseStatus.includes('not_answered'));
// Filter by selected topics
const matchesTopics = selectedTopics.length === 0 ||
r.topics?.some(t => selectedTopics.includes(t));
// Filter by brush date range if active
let matchesBrush = true;
if (brushDateRange && r.centerDate) {
const monthsMap: Record<string, number> = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
const parseMonthYear = (d: string) => {
// Parse "Mon YYYY" format (e.g., "Jan 2024")
const [month, year] = d.split(' ');
return new Date(parseInt(year), monthsMap[month] || 0, 1);
};
const startDate = parseMonthYear(brushDateRange.startDate);
const endDate = parseMonthYear(brushDateRange.endDate);
endDate.setMonth(endDate.getMonth() + 1); // Include the entire end month
matchesBrush = r.centerDate >= startDate && r.centerDate < endDate;
}
return matchesRating && matchesSentiment && matchesSearch && matchesNew && matchesResponseStatus && matchesTopics && matchesBrush;
});
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, selectedTopics, brushDateRange]);
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 toggleResponseStatus = (status: 'answered' | 'not_answered') => {
setSelectedResponseStatus(prev =>
prev.includes(status) ? prev.filter(s => s !== status) : [...prev, status]
);
};
const toggleTopicFilter = (topic: string) => {
setSelectedTopics(prev =>
prev.includes(topic)
? prev.filter(t => t !== topic)
: [...prev, topic]
);
};
const clearAllFilters = () => {
setDateRange('all');
setSelectedRatings([1, 2, 3, 4, 5]);
setSelectedSentiments(['positive', 'neutral', 'negative']);
setSelectedResponseStatus(['answered', 'not_answered']);
setGlobalFilter('');
setShowOnlyNew(false);
setBrushRange(null);
setSelectedTopics([]);
};
const hasActiveFilters = dateRange !== 'all' ||
brushDateRange !== null ||
selectedRatings.length < 5 ||
selectedSentiments.length < 3 ||
selectedResponseStatus.length < 2 ||
globalFilter !== '' ||
showOnlyNew ||
selectedTopics.length > 0;
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<ReviewWithNew>[]>(
() => [
{
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.is_new && (
<span className="px-1.5 py-0.5 bg-green-500 text-white text-[10px] font-bold rounded animate-pulse">
NEW
</span>
)}
{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 [showResponse, setShowResponse] = useState(false);
const [showPhotos, setShowPhotos] = useState(false);
const text = row.original.text || 'No review text';
const sentiment = getSentimentLabel(row.original.rating);
const ownerResponse = row.original.owner_response;
const hasResponse = !!ownerResponse?.text;
const photoUrls = row.original.photo_urls;
const hasPhotos = photoUrls && photoUrls.length > 0;
const topics = row.original.topics;
const hasTopics = topics && topics.length > 0;
return (
<div className="max-w-2xl">
<div className="flex items-center gap-2 mb-2">
<div className={`inline-block px-2 py-1 rounded-md text-xs font-semibold border ${getSentimentColor(sentiment)}`}>
{sentiment.toUpperCase()}
</div>
{hasResponse && (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-300">
<CheckCircle2 className="w-3 h-3" />
Answered
</div>
)}
{hasPhotos && (
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-semibold bg-pink-100 text-pink-800 border border-pink-300">
<Image className="w-3 h-3" />
{photoUrls.length} photo{photoUrls.length > 1 ? 's' : ''}
</div>
)}
</div>
<p className={`text-gray-800 ${!expanded && 'line-clamp-2'}`}>
{text}
</p>
{hasTopics && (
<div className="flex flex-wrap gap-1 mt-2">
{topics.map(topic => (
<span
key={topic}
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full cursor-pointer hover:bg-indigo-200 transition-colors"
onClick={(e) => { e.stopPropagation(); toggleTopicFilter(topic); }}
>
{topic}
</span>
))}
</div>
)}
<div className="flex items-center gap-3 mt-1">
{text.length > 100 && (
<button
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
className="text-blue-700 hover:text-blue-800 text-sm font-semibold"
>
{expanded ? 'Show less' : 'Show more'}
</button>
)}
{hasResponse && (
<button
onClick={(e) => { e.stopPropagation(); setShowResponse(!showResponse); }}
className="text-emerald-700 hover:text-emerald-800 text-sm font-semibold flex items-center gap-1"
>
<MessageCircleReply className="w-3 h-3" />
{showResponse ? 'Hide response' : 'View response'}
</button>
)}
{hasPhotos && (
<button
onClick={(e) => { e.stopPropagation(); setShowPhotos(!showPhotos); }}
className="text-pink-700 hover:text-pink-800 text-sm font-semibold flex items-center gap-1"
>
<Image className="w-3 h-3" />
{showPhotos ? 'Hide photos' : `View ${photoUrls.length} photo${photoUrls.length > 1 ? 's' : ''}`}
</button>
)}
</div>
{showResponse && hasResponse && (
<div className="mt-3 p-3 bg-emerald-50 border-l-4 border-emerald-500 rounded-r-lg">
<div className="flex items-center gap-2 mb-2">
<MessageCircleReply className="w-4 h-4 text-emerald-700" />
<span className="text-xs font-bold text-emerald-800">Owner Response</span>
{ownerResponse.timestamp && (
<span className="text-xs text-emerald-600"> {ownerResponse.timestamp}</span>
)}
</div>
<p className="text-sm text-emerald-900">{ownerResponse.text}</p>
</div>
)}
{showPhotos && hasPhotos && (
<div className="mt-3 p-3 bg-pink-50 border-l-4 border-pink-500 rounded-r-lg">
<div className="flex items-center gap-2 mb-2">
<Image className="w-4 h-4 text-pink-700" />
<span className="text-xs font-bold text-pink-800">Review Photos</span>
</div>
<div className="flex flex-wrap gap-2">
{photoUrls.map((url, idx) => (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
onClick={(e) => e.stopPropagation()}
>
<img
src={url}
alt={`Review photo ${idx + 1}`}
className="w-20 h-20 object-cover rounded-lg border-2 border-pink-200 hover:border-pink-500 transition-colors"
/>
</a>
))}
</div>
</div>
)}
</div>
);
},
},
],
[toggleTopicFilter]
);
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>
<div className="flex items-center gap-3">
<h2 className="text-3xl font-bold text-gray-900">
{businessName || 'Review Analytics'}
</h2>
{businessCategory && (
<span className="px-3 py-1 bg-purple-100 text-purple-800 text-sm font-medium rounded-full border border-purple-300">
{businessCategory}
</span>
)}
</div>
{businessUrl && (
<a
href={businessUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:text-blue-800 hover:underline truncate block max-w-2xl"
title={businessUrl}
>
{businessUrl}
</a>
)}
<div className="flex items-center gap-3 mt-2">
<p className="text-gray-600">{reviews.length} reviews</p>
{hasComparisonData && newCount !== undefined && newCount > 0 && (
<span className="px-3 py-1 bg-green-100 text-green-800 text-sm font-bold rounded-full border-2 border-green-300">
+{newCount} new since last scrape
</span>
)}
</div>
</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>
{/* Response Status Filter */}
<div className="flex items-center gap-3 flex-wrap">
<MessageCircleReply className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Response:</span>
<button
onClick={() => toggleResponseStatus('answered')}
className={`px-4 py-2 rounded-lg font-semibold transition-all border-2 flex items-center gap-2 ${
selectedResponseStatus.includes('answered')
? 'bg-emerald-600 text-white border-emerald-700 shadow-md'
: 'bg-white text-gray-700 border-gray-300 hover:border-emerald-400 hover:bg-emerald-50'
}`}
>
<CheckCircle2 className="w-4 h-4" />
Answered ({stats.responseBreakdown.answered})
</button>
<button
onClick={() => toggleResponseStatus('not_answered')}
className={`px-4 py-2 rounded-lg font-semibold transition-all border-2 flex items-center gap-2 ${
selectedResponseStatus.includes('not_answered')
? 'bg-slate-600 text-white border-slate-700 shadow-md'
: 'bg-white text-gray-700 border-gray-300 hover:border-slate-400 hover:bg-slate-50'
}`}
>
<XCircle className="w-4 h-4" />
Not Answered ({stats.responseBreakdown.notAnswered})
</button>
</div>
{/* New Reviews Filter (if comparison data available) */}
{hasComparisonData && newCount !== undefined && newCount > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<span className="text-green-600 font-bold text-lg">NEW</span>
<span className="font-semibold text-gray-900">New Reviews:</span>
<button
onClick={() => setShowOnlyNew(!showOnlyNew)}
className={`px-4 py-2 rounded-lg font-semibold transition-all border-2 ${
showOnlyNew
? 'bg-green-600 text-white border-green-700 shadow-md'
: 'bg-white text-gray-700 border-gray-300 hover:border-green-400 hover:bg-green-50'
}`}
>
{showOnlyNew ? `Showing ${newCount} New` : `Show Only New (${newCount})`}
</button>
</div>
)}
{/* Topic Filter */}
{availableTopics.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<MessageSquare className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Topics:</span>
{availableTopics.slice(0, 10).map(({topic, count}) => (
<button
key={topic}
onClick={() => toggleTopicFilter(topic)}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
selectedTopics.includes(topic)
? 'bg-indigo-600 text-white border-indigo-700'
: 'bg-indigo-100 text-indigo-700 border-indigo-200 hover:bg-indigo-200'
}`}
>
{topic} ({count})
</button>
))}
{selectedTopics.length > 0 && (
<button
onClick={() => setSelectedTopics([])}
className="px-2 py-1 text-xs text-gray-500 hover:text-gray-700"
>
Clear topics
</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 photos
</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>
{/* Response Rate */}
<div
className={`bg-gradient-to-br border-2 rounded-xl p-4 shadow-md hover:shadow-lg transition-shadow cursor-pointer ${
stats.responseRate >= 50
? 'from-emerald-100 to-emerald-200 border-emerald-400'
: stats.responseRate >= 25
? 'from-amber-100 to-amber-200 border-amber-400'
: 'from-slate-100 to-slate-200 border-slate-400'
}`}
onClick={() => {
setSelectedResponseStatus(['answered']);
setSelectedRatings([1, 2, 3, 4, 5]);
setSelectedSentiments(['positive', 'neutral', 'negative']);
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<MessageCircleReply className={`w-5 h-5 ${
stats.responseRate >= 50 ? 'text-emerald-700' : stats.responseRate >= 25 ? 'text-amber-700' : 'text-slate-700'
}`} />
<span className={`text-sm font-bold ${
stats.responseRate >= 50 ? 'text-emerald-900' : stats.responseRate >= 25 ? 'text-amber-900' : 'text-slate-900'
}`}>Response Rate</span>
</div>
</div>
<div className={`text-3xl font-bold ${
stats.responseRate >= 50 ? 'text-emerald-900' : stats.responseRate >= 25 ? 'text-amber-900' : 'text-slate-900'
}`}>{stats.responseRate.toFixed(0)}%</div>
<div className={`text-xs mt-1 font-medium ${
stats.responseRate >= 50 ? 'text-emerald-800' : stats.responseRate >= 25 ? 'text-amber-800' : 'text-slate-800'
}`}>
{stats.responseBreakdown.answered} of {stats.totalReviews} answered
</div>
</div>
</div>
{/* Key Insights - Trend Metrics */}
<div className="grid md:grid-cols-2 gap-4">
{/* Rating Trend */}
<div className={`border-2 rounded-xl p-5 shadow-md ${
stats.ratingTrend.change > 0
? 'bg-gradient-to-br from-green-50 to-green-100 border-green-400'
: stats.ratingTrend.change < 0
? 'bg-gradient-to-br from-red-50 to-red-100 border-red-400'
: 'bg-gradient-to-br from-gray-50 to-gray-100 border-gray-400'
}`}>
<div className="flex items-center gap-2 mb-3">
<TrendingUp className={`w-6 h-6 ${
stats.ratingTrend.change > 0 ? 'text-green-600' : stats.ratingTrend.change < 0 ? 'text-red-600' : 'text-gray-600'
}`} />
<h3 className="text-lg font-bold text-gray-900">Rating Trend</h3>
</div>
{stats.ratingTrend.olderAvg > 0 && stats.ratingTrend.recentAvg > 0 ? (
<>
<p className={`text-xl font-bold ${
stats.ratingTrend.change > 0 ? 'text-green-700' : stats.ratingTrend.change < 0 ? 'text-red-700' : 'text-gray-700'
}`}>
{stats.ratingTrend.change > 0 ? '📈' : stats.ratingTrend.change < 0 ? '📉' : '➡️'}{' '}
{stats.ratingTrend.change > 0 ? 'Improved' : stats.ratingTrend.change < 0 ? 'Dropped' : 'Stable'} from {stats.ratingTrend.olderAvg} to {stats.ratingTrend.recentAvg}
</p>
<p className="text-sm text-gray-600 mt-2">
{stats.ratingTrend.change > 0
? `Great! Your rating improved by +${stats.ratingTrend.change} stars`
: stats.ratingTrend.change < 0
? `Your average dropped by ${Math.abs(stats.ratingTrend.change)} stars`
: 'Your rating has remained stable'}
</p>
</>
) : (
<p className="text-gray-500">Not enough historical data to show trend</p>
)}
<p className="text-xs text-gray-500 mt-2">{stats.ratingTrend.periodLabel}</p>
</div>
{/* Review Velocity */}
<div className={`border-2 rounded-xl p-5 shadow-md ${
stats.reviewVelocity.changePercent > 0
? 'bg-gradient-to-br from-blue-50 to-blue-100 border-blue-400'
: stats.reviewVelocity.changePercent < 0
? 'bg-gradient-to-br from-orange-50 to-orange-100 border-orange-400'
: 'bg-gradient-to-br from-gray-50 to-gray-100 border-gray-400'
}`}>
<div className="flex items-center gap-2 mb-3">
<Calendar className={`w-6 h-6 ${
stats.reviewVelocity.changePercent > 0 ? 'text-blue-600' : stats.reviewVelocity.changePercent < 0 ? 'text-orange-600' : 'text-gray-600'
}`} />
<h3 className="text-lg font-bold text-gray-900">Review Velocity</h3>
</div>
{stats.reviewVelocity.olderCount > 0 || stats.reviewVelocity.recentCount > 0 ? (
<>
<p className={`text-xl font-bold ${
stats.reviewVelocity.changePercent > 0 ? 'text-blue-700' : stats.reviewVelocity.changePercent < 0 ? 'text-orange-700' : 'text-gray-700'
}`}>
{stats.reviewVelocity.changePercent > 0 ? '🚀' : stats.reviewVelocity.changePercent < 0 ? '⚠️' : '➡️'}{' '}
{Math.abs(stats.reviewVelocity.changePercent)}% {stats.reviewVelocity.changePercent >= 0 ? 'more' : 'fewer'} reviews
</p>
<p className="text-sm text-gray-600 mt-2">
{stats.reviewVelocity.recentCount} reviews recently vs {stats.reviewVelocity.olderCount} previously
</p>
</>
) : (
<p className="text-gray-500">Not enough data to calculate velocity</p>
)}
<p className="text-xs text-gray-500 mt-2">{stats.reviewVelocity.periodLabel}</p>
</div>
</div>
{/* Review Topics - from Google Maps */}
{reviewTopics && reviewTopics.length > 0 && (
<div className="bg-white border-2 border-gray-300 rounded-xl p-5 shadow-md">
<div className="flex items-center gap-2 mb-4">
<MessageSquare className="w-6 h-6 text-indigo-600" />
<h3 className="text-lg font-bold text-gray-900">What People Talk About</h3>
<span className="text-sm text-gray-500">({reviewTopics.length} topics from Google)</span>
</div>
<div className="flex flex-wrap gap-2">
{reviewTopics.slice(0, 15).map((topic, idx) => (
<div
key={idx}
className="px-3 py-1.5 bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-full flex items-center gap-2"
>
<span className="text-sm font-medium text-indigo-800">{topic.topic}</span>
<span className="text-xs bg-indigo-200 text-indigo-900 px-1.5 py-0.5 rounded-full font-bold">
{topic.count}
</span>
</div>
))}
</div>
{reviewTopics.length > 15 && (
<p className="text-sm text-gray-500 mt-3">+{reviewTopics.length - 15} more topics</p>
)}
</div>
)}
{/* Rating & Volume Timeline */}
{timelineData.length > 0 && (
<div className={`bg-white rounded-xl p-6 shadow-md transition-all ${
brushDateRange ? 'border-3 border-blue-500 ring-2 ring-blue-200' : 'border-2 border-gray-300'
}`}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900">Rating & Volume Over Time</h3>
<p className="text-sm text-gray-500 mt-1">
{brushDateRange
? `Filtering: ${brushDateRange.startDate} to ${brushDateRange.endDate}`
: 'Drag the handles below to filter reviews by date range'}
</p>
</div>
<div className="flex items-center gap-2">
{brushDateRange && (
<button
onClick={() => setBrushRange({ startIndex: 0, endIndex: timelineData.length - 1 })}
className="px-3 py-1.5 text-sm font-semibold rounded-md bg-red-100 text-red-700 hover:bg-red-200 transition-all flex items-center gap-1"
>
<X className="w-4 h-4" />
Clear Range
</button>
)}
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
{(['3m', '6m', '1y', 'all'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRangeFilter(range)}
className={`px-3 py-1.5 text-sm font-semibold rounded-md transition-all ${
activeTimeRange === range
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-200'
}`}
>
{range === 'all' ? 'All' : range.toUpperCase()}
</button>
))}
</div>
</div>
</div>
<ResponsiveContainer width="100%" height={400}>
<ComposedChart data={timelineData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
tick={{ fill: '#374151', fontWeight: 600, fontSize: 12 }}
tickLine={{ stroke: '#9ca3af' }}
/>
<YAxis
yAxisId="rating"
domain={[0, 5]}
ticks={[0, 1, 2, 3, 4, 5]}
tick={{ fill: '#374151', fontWeight: 600 }}
tickLine={{ stroke: '#9ca3af' }}
label={{ value: 'Rating', angle: -90, position: 'insideLeft', fill: '#6b7280' }}
/>
<YAxis
yAxisId="count"
orientation="right"
tick={{ fill: '#374151', fontWeight: 600 }}
tickLine={{ stroke: '#9ca3af' }}
label={{ value: 'Reviews', angle: 90, position: 'insideRight', fill: '#6b7280' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '2px solid #3b82f6',
borderRadius: '8px',
fontWeight: 600
}}
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-white border-2 border-blue-600 rounded-lg p-3 shadow-lg">
<p className="font-bold text-gray-900 mb-2">{label}</p>
<p className="text-sm text-gray-600">
<span className="inline-block w-3 h-3 rounded-full bg-blue-500 mr-2"></span>
Avg Rating: {Number(payload.find(p => p.dataKey === 'rollingAvg')?.value || 0).toFixed(2)}
</p>
<p className="text-sm text-gray-600">
<span className="inline-block w-3 h-3 rounded-full bg-green-400 mr-2"></span>
Reviews: {payload.find(p => p.dataKey === 'count')?.value || 0}
</p>
</div>
);
}
return null;
}}
/>
<Legend />
<Bar
yAxisId="count"
dataKey="count"
fill="#86efac"
opacity={0.7}
name="Review Volume"
radius={[4, 4, 0, 0]}
/>
<Line
yAxisId="rating"
type="monotone"
dataKey="rollingAvg"
stroke="#3b82f6"
strokeWidth={3}
name="Rating (3-Mo Avg)"
dot={{ fill: '#2563eb', r: 4 }}
/>
<Brush
dataKey="date"
height={50}
stroke="#3b82f6"
fill="#f0f9ff"
travellerWidth={12}
tickFormatter={(value) => value}
startIndex={brushRange?.startIndex ?? 0}
endIndex={brushRange?.endIndex ?? (timelineData.length - 1)}
onChange={(range: any) => {
if (range && typeof range.startIndex === 'number' && typeof range.endIndex === 'number') {
setBrushRange({ startIndex: range.startIndex, endIndex: range.endIndex });
}
}}
>
<LineChart data={timelineData}>
<Line
type="monotone"
dataKey="rollingAvg"
stroke="#93c5fd"
strokeWidth={1}
dot={false}
/>
</LineChart>
</Brush>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Charts Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Rating Distribution - Interactive */}
<div className={`bg-white rounded-xl p-6 shadow-md transition-all cursor-pointer ${
selectedRatings.length === 1
? 'border-3 border-blue-500 ring-2 ring-blue-200'
: 'border-2 border-gray-300 hover:border-blue-400'
}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">
Rating Distribution
</h3>
{selectedRatings.length === 1 && (
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-bold rounded-full">
Filtering: {selectedRatings[0]}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedRatings([1, 2, 3, 4, 5]);
}}
className="p-1 hover:bg-gray-200 rounded-full transition-colors"
title="Clear filter"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
<ResponsiveContainer width="100%" height={250}>
<BarChart
data={stats.ratingDistribution}
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-3 shadow-lg">
<p className="font-bold text-gray-900 text-lg">{payload[0].payload.rating} reviews</p>
<p className="text-sm text-gray-600">{payload[0].value} reviews ({payload[0].payload.percentage.toFixed(1)}%)</p>
</div>
);
}
return null;
}}
/>
<Bar
dataKey="count"
radius={[8, 8, 0, 0]}
onClick={(data: any) => {
if (data && data.rating) {
setSelectedRatings([data.rating]);
setSelectedSentiments(['positive', 'neutral', 'negative']);
}
}}
background={({ x, y, width, height, index }: any) => {
const rating = stats.ratingDistribution[index]?.rating;
const isSelected = selectedRatings.length === 1 && selectedRatings[0] === rating;
return (
<rect
x={x}
y={0}
width={width}
height={250}
fill={isSelected ? 'rgba(59, 130, 246, 0.1)' : 'transparent'}
style={{ cursor: 'pointer' }}
onClick={() => {
setSelectedRatings([rating]);
setSelectedSentiments(['positive', 'neutral', 'negative']);
}}
/>
);
}}
>
{stats.ratingDistribution.map((entry) => (
<Cell
key={`cell-${entry.rating}`}
fill={selectedRatings.length === 1 && selectedRatings[0] === entry.rating ? '#1d4ed8' : '#93c5fd'}
stroke={selectedRatings.length === 1 && selectedRatings[0] === entry.rating ? '#1e40af' : 'transparent'}
strokeWidth={selectedRatings.length === 1 && selectedRatings[0] === entry.rating ? 3 : 0}
style={{ cursor: 'pointer' }}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Sentiment Breakdown - Interactive */}
<div className={`bg-white rounded-xl p-6 shadow-md transition-all cursor-pointer ${
selectedSentiments.length === 1
? 'border-3 border-blue-500 ring-2 ring-blue-200'
: 'border-2 border-gray-300 hover:border-blue-400'
}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">
Sentiment Breakdown
</h3>
{selectedSentiments.length === 1 && (
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-bold rounded-full ${
selectedSentiments[0] === 'positive' ? 'bg-green-100 text-green-800' :
selectedSentiments[0] === 'neutral' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
Filtering: {selectedSentiments[0].charAt(0).toUpperCase() + selectedSentiments[0].slice(1)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedSentiments(['positive', 'neutral', 'negative']);
}}
className="p-1 hover:bg-gray-200 rounded-full transition-colors"
title="Clear filter"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
<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 }: any) => `${name || ''} ${((percent || 0) * 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={selectedSentiments.length === 1 && selectedSentiments[0] === 'positive' ? '#15803d' : COLORS.positive}
stroke={selectedSentiments.length === 1 && selectedSentiments[0] === 'positive' ? '#14532d' : 'transparent'}
strokeWidth={selectedSentiments.length === 1 && selectedSentiments[0] === 'positive' ? 3 : 0}
/>
<Cell
fill={selectedSentiments.length === 1 && selectedSentiments[0] === 'neutral' ? '#a16207' : COLORS.neutral}
stroke={selectedSentiments.length === 1 && selectedSentiments[0] === 'neutral' ? '#713f12' : 'transparent'}
strokeWidth={selectedSentiments.length === 1 && selectedSentiments[0] === 'neutral' ? 3 : 0}
/>
<Cell
fill={selectedSentiments.length === 1 && selectedSentiments[0] === 'negative' ? '#b91c1c' : COLORS.negative}
stroke={selectedSentiments.length === 1 && selectedSentiments[0] === 'negative' ? '#7f1d1d' : 'transparent'}
strokeWidth={selectedSentiments.length === 1 && selectedSentiments[0] === 'negative' ? 3 : 0}
/>
</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-3 shadow-lg">
<p className="font-bold text-gray-900 text-lg">{payload[0].name}</p>
<p className="text-sm text-gray-600">{payload[0].value} reviews</p>
</div>
);
}
return null;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Response Status - Interactive Donut Chart */}
<div className={`bg-white rounded-xl p-6 shadow-md transition-all cursor-pointer ${
selectedResponseStatus.length === 1
? 'border-3 border-blue-500 ring-2 ring-blue-200'
: 'border-2 border-gray-300 hover:border-blue-400'
}`}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">
Response Status
</h3>
{selectedResponseStatus.length === 1 && (
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-bold rounded-full ${
selectedResponseStatus[0] === 'answered' ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-800'
}`}>
Filtering: {selectedResponseStatus[0] === 'answered' ? 'Answered' : 'Not Answered'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setSelectedResponseStatus(['answered', 'not_answered']);
}}
className="p-1 hover:bg-gray-200 rounded-full transition-colors"
title="Clear filter"
>
<X className="w-4 h-4 text-gray-600" />
</button>
</div>
)}
</div>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie
data={[
{ name: 'Answered', value: stats.responseBreakdown.answered, status: 'answered' },
{ name: 'Not Answered', value: stats.responseBreakdown.notAnswered, status: 'not_answered' },
]}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={80}
labelLine={false}
label={({ name, percent }: any) => `${name || ''} ${((percent || 0) * 100).toFixed(0)}%`}
fill="#8884d8"
dataKey="value"
style={{ fontWeight: 700, fontSize: '12px', cursor: 'pointer' }}
onClick={(data) => {
if (data && data.status) {
setSelectedResponseStatus([data.status as 'answered' | 'not_answered']);
}
}}
>
<Cell
fill={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'answered' ? '#047857' : '#10b981'}
stroke={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'answered' ? '#064e3b' : 'transparent'}
strokeWidth={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'answered' ? 3 : 0}
/>
<Cell
fill={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'not_answered' ? '#334155' : '#94a3b8'}
stroke={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'not_answered' ? '#1e293b' : 'transparent'}
strokeWidth={selectedResponseStatus.length === 1 && selectedResponseStatus[0] === 'not_answered' ? 3 : 0}
/>
</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-3 shadow-lg">
<p className="font-bold text-gray-900 text-lg">{payload[0].name}</p>
<p className="text-sm text-gray-600">{payload[0].value} reviews ({((payload[0].value as number / stats.totalReviews) * 100).toFixed(1)}%)</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">
<div>
<h3 className="text-xl font-bold text-gray-900">Review Details</h3>
<p className="text-sm text-gray-500">Click on a row to view full review data</p>
</div>
<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-blue-50 cursor-pointer transition-colors"
onClick={() => setSelectedReview(row.original)}
>
{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>
{/* Review Detail Modal */}
{selectedReview && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={() => setSelectedReview(null)}
>
<div
className="bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="sticky top-0 bg-white border-b-2 border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl">
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-gray-900">Review Details</h3>
{selectedReview.is_new && (
<span className="px-2 py-1 bg-green-500 text-white text-xs font-bold rounded animate-pulse">
NEW
</span>
)}
{selectedReview.owner_response?.text ? (
<span className="px-2 py-1 bg-emerald-500 text-white text-xs font-bold rounded flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
ANSWERED
</span>
) : (
<span className="px-2 py-1 bg-slate-400 text-white text-xs font-bold rounded flex items-center gap-1">
<XCircle className="w-3 h-3" />
NOT ANSWERED
</span>
)}
{selectedReview.photo_urls && selectedReview.photo_urls.length > 0 && (
<span className="px-2 py-1 bg-pink-500 text-white text-xs font-bold rounded flex items-center gap-1">
<Image className="w-3 h-3" />
{selectedReview.photo_urls.length} PHOTO{selectedReview.photo_urls.length > 1 ? 'S' : ''}
</span>
)}
</div>
<button
onClick={() => setSelectedReview(null)}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-gray-600" />
</button>
</div>
{/* Modal Content */}
<div className="p-6 space-y-6">
{/* Author Info */}
<div className="flex items-center gap-4">
{selectedReview.avatar_url ? (
<img
src={selectedReview.avatar_url}
alt={selectedReview.author}
className="w-16 h-16 rounded-full border-2 border-gray-200"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 flex items-center justify-center text-2xl font-bold text-gray-500">
{selectedReview.author.charAt(0).toUpperCase()}
</div>
)}
<div>
<h4 className="text-lg font-bold text-gray-900">{selectedReview.author}</h4>
{selectedReview.profile_url && (
<a
href={selectedReview.profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1"
>
View Profile <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
</div>
{/* Rating */}
<div className="bg-gray-50 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-6 h-6 ${i < selectedReview.rating ? 'text-yellow-500 fill-yellow-500' : 'text-gray-300'}`}
/>
))}
</div>
<span className="text-2xl font-bold text-gray-900">{selectedReview.rating}/5</span>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${getSentimentColor(getSentimentLabel(selectedReview.rating))}`}>
{getSentimentLabel(selectedReview.rating).toUpperCase()}
</span>
</div>
</div>
{/* Date Info */}
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-5 h-5 text-blue-700" />
<span className="font-semibold text-blue-900">Date</span>
</div>
<p className="text-lg font-medium text-gray-900">{selectedReview.date_text}</p>
{selectedReview.minDate && selectedReview.maxDate && selectedReview.centerDate && (
<div className="mt-2 text-sm text-gray-600 space-y-1">
<p>
<span className="font-medium">Estimated Range:</span>{' '}
{selectedReview.maxDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} -{' '}
{selectedReview.minDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</p>
<p className="text-purple-700 font-semibold">
Center Date: {selectedReview.centerDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}
</p>
</div>
)}
</div>
{/* Review Text */}
<div>
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Review</span>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-gray-800 leading-relaxed whitespace-pre-wrap">
{selectedReview.text || <span className="text-gray-500 italic">No review text provided</span>}
</p>
</div>
</div>
{/* Review Topics */}
{selectedReview.topics && selectedReview.topics.length > 0 && (
<div className="bg-indigo-50 rounded-xl p-4 border-2 border-indigo-200">
<div className="flex items-center gap-2 mb-3">
<MessageSquare className="w-5 h-5 text-indigo-700" />
<span className="font-semibold text-indigo-900">Topics</span>
<span className="px-2 py-0.5 bg-indigo-200 text-indigo-800 text-xs font-bold rounded-full">
{selectedReview.topics.length} topic{selectedReview.topics.length > 1 ? 's' : ''}
</span>
</div>
<div className="flex flex-wrap gap-2">
{selectedReview.topics.map(topic => (
<button
key={topic}
onClick={() => {
toggleTopicFilter(topic);
setSelectedReview(null);
}}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-all border ${
selectedTopics.includes(topic)
? 'bg-indigo-600 text-white border-indigo-700'
: 'bg-white text-indigo-700 border-indigo-300 hover:bg-indigo-100'
}`}
>
{topic}
</button>
))}
</div>
</div>
)}
{/* Owner Response */}
{selectedReview.owner_response?.text ? (
<div className="bg-emerald-50 rounded-xl p-4 border-2 border-emerald-200">
<div className="flex items-center gap-2 mb-3">
<MessageCircleReply className="w-5 h-5 text-emerald-700" />
<span className="font-semibold text-emerald-900">Owner Response</span>
<span className="px-2 py-0.5 bg-emerald-200 text-emerald-800 text-xs font-bold rounded-full">
Answered
</span>
</div>
{selectedReview.owner_response.timestamp && (
<p className="text-xs text-emerald-700 mb-2">
Responded: {selectedReview.owner_response.timestamp}
</p>
)}
<div className="bg-white rounded-lg p-3 border border-emerald-200">
<p className="text-emerald-900 leading-relaxed whitespace-pre-wrap">
{selectedReview.owner_response.text}
</p>
</div>
</div>
) : (
<div className="bg-slate-50 rounded-xl p-4 border-2 border-slate-200">
<div className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-slate-500" />
<span className="font-semibold text-slate-700">No Owner Response</span>
<span className="px-2 py-0.5 bg-slate-200 text-slate-700 text-xs font-bold rounded-full">
Not Answered
</span>
</div>
<p className="text-sm text-slate-500 mt-2">
The business owner has not responded to this review.
</p>
</div>
)}
{/* Review Photos */}
{selectedReview.photo_urls && selectedReview.photo_urls.length > 0 && (
<div className="bg-pink-50 rounded-xl p-4 border-2 border-pink-200">
<div className="flex items-center gap-2 mb-3">
<Image className="w-5 h-5 text-pink-700" />
<span className="font-semibold text-pink-900">Review Photos</span>
<span className="px-2 py-0.5 bg-pink-200 text-pink-800 text-xs font-bold rounded-full">
{selectedReview.photo_urls.length} photo{selectedReview.photo_urls.length > 1 ? 's' : ''}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{selectedReview.photo_urls.map((url, idx) => (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
<div className="relative overflow-hidden rounded-lg border-2 border-pink-200 hover:border-pink-500 transition-colors">
<img
src={url}
alt={`Review photo ${idx + 1}`}
className="w-full h-32 object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity flex items-center justify-center">
<ExternalLink className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</a>
))}
</div>
</div>
)}
{/* Additional Data (if any) */}
{selectedReview.review_id && (
<div className="border-t-2 border-gray-200 pt-4">
<h5 className="font-semibold text-gray-900 mb-3">Additional Information</h5>
<div className="space-y-2 text-sm">
<p className="text-gray-600">
<span className="font-medium">Review ID:</span> {selectedReview.review_id}
</p>
</div>
</div>
)}
{/* Raw JSON Data */}
<details className="border-t-2 border-gray-200 pt-4">
<summary className="cursor-pointer text-sm font-medium text-gray-600 hover:text-gray-900">
View Raw Data (JSON)
</summary>
<pre className="mt-3 bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-xs">
{JSON.stringify(selectedReview, null, 2)}
</pre>
</details>
</div>
{/* Modal Footer */}
<div className="sticky bottom-0 bg-white border-t-2 border-gray-200 px-6 py-4 rounded-b-2xl">
<button
onClick={() => setSelectedReview(null)}
className="w-full py-3 bg-gray-900 text-white rounded-lg font-semibold hover:bg-gray-800 transition-colors"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}