- Transfer user's browser fingerprint (user-agent, viewport, timezone, language, geolocation) to Chrome for more authentic scraping - Display review topics from Google Maps in analytics dashboard - Show business category badge in analytics header - Fix date_text null handling in analytics (handle undefined/timestamp fields) - Add review_topics and business_category to JobStatus interface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1647 lines
75 KiB
TypeScript
1647 lines
75 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;
|
|
}
|
|
|
|
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);
|
|
|
|
// 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]);
|
|
|
|
// 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, 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 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 && matchesBrush;
|
|
});
|
|
}, [dateFilteredReviews, selectedRatings, selectedSentiments, selectedResponseStatus, globalFilter, showOnlyNew, 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 clearAllFilters = () => {
|
|
setDateRange('all');
|
|
setSelectedRatings([1, 2, 3, 4, 5]);
|
|
setSelectedSentiments(['positive', 'neutral', 'negative']);
|
|
setSelectedResponseStatus(['answered', 'not_answered']);
|
|
setGlobalFilter('');
|
|
setShowOnlyNew(false);
|
|
setBrushRange(null);
|
|
};
|
|
|
|
const hasActiveFilters = dateRange !== 'all' ||
|
|
brushDateRange !== null ||
|
|
selectedRatings.length < 5 ||
|
|
selectedSentiments.length < 3 ||
|
|
selectedResponseStatus.length < 2 ||
|
|
globalFilter !== '' ||
|
|
showOnlyNew;
|
|
|
|
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;
|
|
|
|
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>
|
|
<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>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[]
|
|
);
|
|
|
|
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>
|
|
)}
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|