'use client'; import { useState, useEffect, useMemo } from 'react'; import { X, Star, ExternalLink, Calendar, User, MapPin, Loader2, AlertCircle, } from 'lucide-react'; import type { FullReview, ReviewSpan } from '../types'; import { VALENCE_COLORS, VALENCE_LABELS, INTENSITY_LABELS, DOMAIN_LABELS, DOMAIN_COLORS, } from '../types'; interface ReviewModalProps { reviewId: string | null; source?: string; highlightSpanId?: string | null; onClose: () => void; } /** * Modal showing a full review with classified spans highlighted. * Enables drill-down from any aggregate metric to the raw source data. */ export function ReviewModal({ reviewId, source = 'google', highlightSpanId, onClose, }: ReviewModalProps) { const [review, setReview] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Fetch review when reviewId changes useEffect(() => { if (!reviewId) { setReview(null); return; } const fetchReview = async () => { setLoading(true); setError(null); try { const response = await fetch( `/api/pipelines/reviewiq/reviews/${encodeURIComponent(reviewId)}?source=${encodeURIComponent(source)}` ); if (!response.ok) { throw new Error(`Failed to fetch review: ${response.statusText}`); } const data = await response.json(); setReview(data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to fetch review'); } finally { setLoading(false); } }; fetchReview(); }, [reviewId, source]); // Build highlighted text with spans marked using text-based matching // (offsets in DB are unreliable, so we find spans by searching for their text) const highlightedText = useMemo(() => { if (!review?.review_text || !review.spans.length) { return review?.review_text || ''; } const text = review.review_text; // Find all span positions using text search const spanPositions: Array<{ start: number; end: number; span: ReviewSpan; }> = []; for (const span of review.spans) { if (!span.span_text) continue; // Try exact match first let idx = text.indexOf(span.span_text); // If not found, try case-insensitive if (idx === -1) { idx = text.toLowerCase().indexOf(span.span_text.toLowerCase()); } if (idx !== -1) { spanPositions.push({ start: idx, end: idx + span.span_text.length, span, }); } } if (spanPositions.length === 0) { // No matches found, return plain text return review.review_text; } // Sort by position and remove overlaps (keep first occurrence) spanPositions.sort((a, b) => a.start - b.start); const nonOverlapping: typeof spanPositions = []; let lastEnd = 0; for (const pos of spanPositions) { if (pos.start >= lastEnd) { nonOverlapping.push(pos); lastEnd = pos.end; } } // Build segments const segments: Array<{ text: string; span: ReviewSpan | null }> = []; let currentPos = 0; for (const pos of nonOverlapping) { // Add text before this span if (pos.start > currentPos) { segments.push({ text: text.slice(currentPos, pos.start), span: null }); } // Add the span (use actual text from review, not span_text, to preserve case) segments.push({ text: text.slice(pos.start, pos.end), span: pos.span }); currentPos = pos.end; } // Add remaining text if (currentPos < text.length) { segments.push({ text: text.slice(currentPos), span: null }); } return segments; }, [review]); if (!reviewId) return null; return (
{/* Header */}

Full Review

{/* Content */}
{loading ? (
) : error ? (

{error}

) : review ? (
{/* Review Metadata */}
{/* Author */} {review.author_name && (
{review.author_name}
)} {/* Business */} {review.business_name && (
{review.business_name}
)} {/* Date */} {review.review_time && (
{new Date(review.review_time).toLocaleDateString()}
)}
{/* Rating */} {review.rating !== null && (
{Array.from({ length: 5 }).map((_, i) => ( ))}
)}
{/* Review Text with Highlighted Spans */}

{Array.isArray(highlightedText) ? ( highlightedText.map((segment, idx) => segment.span ? ( {segment.text} ) : ( {segment.text} ) ) ) : ( highlightedText )}

{/* Legend */}
Positive
Neutral
Negative
| Underline = URT Domain
{/* Classified Spans List */}

Classified Spans ({review.spans.length})

{review.spans.map((span) => (

“{span.span_text}”

{/* URT Code */} {span.urt_primary && ( {span.urt_primary} )} {/* Valence */} {span.valence && ( {VALENCE_LABELS[span.valence]} )} {/* Intensity */} {span.intensity && ( {INTENSITY_LABELS[span.intensity]} )}
{/* Domain label */} {span.urt_primary && (
{DOMAIN_LABELS[span.urt_primary[0]]} Domain {span.entity && ` ยท Entity: ${span.entity}`}
)}
))}
{/* External Link */} {review.review_url && ( )}
) : null}
); }