'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([{ id: 'date', desc: true }]); // Default: newest first const [columnFilters, setColumnFiltersState] = useState([]); const [globalFilter, setGlobalFilter] = useState(''); const [selectedRatings, setSelectedRatings] = useState([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('all'); const [selectedReview, setSelectedReview] = useState(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 = { 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 = { 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 = { 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[]>( () => [ { accessorKey: 'author', header: ({ column }) => { return ( ); }, cell: ({ row }) => (
{row.original.is_new && ( NEW )} {row.original.avatar_url && ( {row.original.author} )} {row.original.author}
), }, { accessorKey: 'rating', header: ({ column }) => { return ( ); }, cell: ({ row }) => (
{[...Array(5)].map((_, i) => ( ))} {row.original.rating}
), filterFn: (row, id, value) => { return value.includes(row.getValue(id)); }, }, { accessorKey: 'centerDate', id: 'date', header: ({ column }) => { return ( ); }, 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 (
{row.original.date_text}
{row.original.minDate && row.original.maxDate && row.original.centerDate && (
Range: {formatDate(row.original.maxDate)} - {formatDate(row.original.minDate)}
Center: {formatDate(row.original.centerDate)}
±{getUncertaintyDays(row.original.minDate, row.original.maxDate)} days uncertainty
)}
); }, }, { 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 (
{sentiment.toUpperCase()}
{hasResponse && (
Answered
)} {hasPhotos && (
{photoUrls.length} photo{photoUrls.length > 1 ? 's' : ''}
)}

{text}

{text.length > 100 && ( )} {hasResponse && ( )} {hasPhotos && ( )}
{showResponse && hasResponse && (
Owner Response {ownerResponse.timestamp && ( • {ownerResponse.timestamp} )}

{ownerResponse.text}

)} {showPhotos && hasPhotos && (
Review Photos
{photoUrls.map((url, idx) => ( e.stopPropagation()} > {`Review ))}
)}
); }, }, ], [] ); const table = useReactTable({ data: filteredReviews, columns, state: { sorting, }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageSize: 10, }, }, }); return (
{/* Header */}

{businessName || 'Review Analytics'}

{businessCategory && ( {businessCategory} )}
{businessUrl && ( {businessUrl} )}

{reviews.length} reviews

{hasComparisonData && newCount !== undefined && newCount > 0 && ( +{newCount} new since last scrape )}
{/* Enhanced Filters */}
{/* Time Period Filter */}
Time Period: {(['week', 'month', 'year', 'all'] as DateRange[]).map((range) => ( ))}
{/* Sentiment Filter */}
Sentiment: {(['positive', 'neutral', 'negative'] as const).map((sentiment) => ( ))}
{/* Response Status Filter */}
Response:
{/* New Reviews Filter (if comparison data available) */} {hasComparisonData && newCount !== undefined && newCount > 0 && (
NEW New Reviews:
)} {/* Filter Summary */}
Showing {filteredReviews.length} of {reviews.length} reviews {hasActiveFilters && (filtered)} {hasActiveFilters && ( )}
{/* KPI Cards */}
{/* Average Rating */}
Avg Rating
{stats.averageRating.toFixed(1)}★
{stats.totalReviews} total reviews
{/* Positive Reviews */}
{ setSelectedSentiments(['positive']); setDateRange('all'); }}>
Positive
{stats.sentimentBreakdown.positive}
{stats.sentimentScore.toFixed(0)}% positive (4-5★)
{/* Neutral Reviews */}
{ setSelectedSentiments(['neutral']); setDateRange('all'); }}>
Neutral
{stats.sentimentBreakdown.neutral}
{((stats.sentimentBreakdown.neutral / stats.totalReviews) * 100).toFixed(0)}% neutral (3★)
{/* Negative Reviews - Alert */}
{ setSelectedSentiments(['negative']); setDateRange('all'); }}>
Negative
{stats.negativeReviews}
{((stats.negativeReviews / stats.totalReviews) * 100).toFixed(0)}% negative (1-2★)
{/* Recent Activity */}
setDateRange('month')}>
Recent
{stats.recentReviews}
last 30 days
{/* Review Length */}
Avg Length
{stats.avgReviewLength}
words per review
{/* Photos */}
With Photos
{stats.photoCount}
{((stats.photoCount / stats.totalReviews) * 100).toFixed(0)}% have photos
{/* Total Reviews */}
Total
{stats.totalReviews}
all time
{/* Response Rate */}
= 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']); }} >
= 50 ? 'text-emerald-700' : stats.responseRate >= 25 ? 'text-amber-700' : 'text-slate-700' }`} /> = 50 ? 'text-emerald-900' : stats.responseRate >= 25 ? 'text-amber-900' : 'text-slate-900' }`}>Response Rate
= 50 ? 'text-emerald-900' : stats.responseRate >= 25 ? 'text-amber-900' : 'text-slate-900' }`}>{stats.responseRate.toFixed(0)}%
= 50 ? 'text-emerald-800' : stats.responseRate >= 25 ? 'text-amber-800' : 'text-slate-800' }`}> {stats.responseBreakdown.answered} of {stats.totalReviews} answered
{/* Key Insights - Trend Metrics */}
{/* Rating Trend */}
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' }`}>
0 ? 'text-green-600' : stats.ratingTrend.change < 0 ? 'text-red-600' : 'text-gray-600' }`} />

Rating Trend

{stats.ratingTrend.olderAvg > 0 && stats.ratingTrend.recentAvg > 0 ? ( <>

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}★

{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'}

) : (

Not enough historical data to show trend

)}

{stats.ratingTrend.periodLabel}

{/* Review Velocity */}
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' }`}>
0 ? 'text-blue-600' : stats.reviewVelocity.changePercent < 0 ? 'text-orange-600' : 'text-gray-600' }`} />

Review Velocity

{stats.reviewVelocity.olderCount > 0 || stats.reviewVelocity.recentCount > 0 ? ( <>

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

{stats.reviewVelocity.recentCount} reviews recently vs {stats.reviewVelocity.olderCount} previously

) : (

Not enough data to calculate velocity

)}

{stats.reviewVelocity.periodLabel}

{/* Review Topics - from Google Maps */} {reviewTopics && reviewTopics.length > 0 && (

What People Talk About

({reviewTopics.length} topics from Google)
{reviewTopics.slice(0, 15).map((topic, idx) => (
{topic.topic} {topic.count}
))}
{reviewTopics.length > 15 && (

+{reviewTopics.length - 15} more topics

)}
)} {/* Rating & Volume Timeline */} {timelineData.length > 0 && (

Rating & Volume Over Time

{brushDateRange ? `Filtering: ${brushDateRange.startDate} to ${brushDateRange.endDate}` : 'Drag the handles below to filter reviews by date range'}

{brushDateRange && ( )}
{(['3m', '6m', '1y', 'all'] as const).map((range) => ( ))}
{ if (active && payload && payload.length) { return (

{label}

Avg Rating: {Number(payload.find(p => p.dataKey === 'rollingAvg')?.value || 0).toFixed(2)}★

Reviews: {payload.find(p => p.dataKey === 'count')?.value || 0}

); } return null; }} /> 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 }); } }} >
)} {/* Charts Grid */}
{/* Rating Distribution - Interactive */}

Rating Distribution

{selectedRatings.length === 1 && (
Filtering: {selectedRatings[0]}★
)}
{ if (active && payload && payload.length) { return (

{payload[0].payload.rating}★ reviews

{payload[0].value} reviews ({payload[0].payload.percentage.toFixed(1)}%)

); } return null; }} /> { 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 ( { setSelectedRatings([rating]); setSelectedSentiments(['positive', 'neutral', 'negative']); }} /> ); }} > {stats.ratingDistribution.map((entry) => ( ))}
{/* Sentiment Breakdown - Interactive */}

Sentiment Breakdown

{selectedSentiments.length === 1 && (
Filtering: {selectedSentiments[0].charAt(0).toUpperCase() + selectedSentiments[0].slice(1)}
)}
`${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]); } }} > { if (active && payload && payload.length) { return (

{payload[0].name}

{payload[0].value} reviews

); } return null; }} />
{/* Response Status - Interactive Donut Chart */}

Response Status

{selectedResponseStatus.length === 1 && (
Filtering: {selectedResponseStatus[0] === 'answered' ? 'Answered' : 'Not Answered'}
)}
`${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']); } }} > { if (active && payload && payload.length) { return (

{payload[0].name}

{payload[0].value} reviews ({((payload[0].value as number / stats.totalReviews) * 100).toFixed(1)}%)

); } return null; }} />
{/* Top Keywords */}

Top Keywords

{/* Reviews Table */}

Review Details

Click on a row to view full review data

{/* Search */}
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" />
{/* Table */}
{table.getHeaderGroups().map(headerGroup => ( {headerGroup.headers.map(header => ( ))} ))} {table.getRowModel().rows.map(row => ( setSelectedReview(row.original)} > {row.getVisibleCells().map(cell => ( ))} ))}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* Pagination */}
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
{/* Review Detail Modal */} {selectedReview && (
setSelectedReview(null)} >
e.stopPropagation()} > {/* Modal Header */}

Review Details

{selectedReview.is_new && ( NEW )} {selectedReview.owner_response?.text ? ( ANSWERED ) : ( NOT ANSWERED )} {selectedReview.photo_urls && selectedReview.photo_urls.length > 0 && ( {selectedReview.photo_urls.length} PHOTO{selectedReview.photo_urls.length > 1 ? 'S' : ''} )}
{/* Modal Content */}
{/* Author Info */}
{selectedReview.avatar_url ? ( {selectedReview.author} ) : (
{selectedReview.author.charAt(0).toUpperCase()}
)}

{selectedReview.author}

{selectedReview.profile_url && ( View Profile )}
{/* Rating */}
{[...Array(5)].map((_, i) => ( ))}
{selectedReview.rating}/5 {getSentimentLabel(selectedReview.rating).toUpperCase()}
{/* Date Info */}
Date

{selectedReview.date_text}

{selectedReview.minDate && selectedReview.maxDate && selectedReview.centerDate && (

Estimated Range:{' '} {selectedReview.maxDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} -{' '} {selectedReview.minDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}

Center Date: {selectedReview.centerDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}

)}
{/* Review Text */}
Review

{selectedReview.text || No review text provided}

{/* Owner Response */} {selectedReview.owner_response?.text ? (
Owner Response Answered
{selectedReview.owner_response.timestamp && (

Responded: {selectedReview.owner_response.timestamp}

)}

{selectedReview.owner_response.text}

) : (
No Owner Response Not Answered

The business owner has not responded to this review.

)} {/* Review Photos */} {selectedReview.photo_urls && selectedReview.photo_urls.length > 0 && (
Review Photos {selectedReview.photo_urls.length} photo{selectedReview.photo_urls.length > 1 ? 's' : ''}
{selectedReview.photo_urls.map((url, idx) => (
{`Review
))}
)} {/* Additional Data (if any) */} {selectedReview.review_id && (
Additional Information

Review ID: {selectedReview.review_id}

)} {/* Raw JSON Data */}
View Raw Data (JSON)
                  {JSON.stringify(selectedReview, null, 2)}
                
{/* Modal Footer */}
)}
); }