diff --git a/web/components/reviewiq/ReviewIQDashboard.tsx b/web/components/reviewiq/ReviewIQDashboard.tsx
new file mode 100644
index 0000000..182647f
--- /dev/null
+++ b/web/components/reviewiq/ReviewIQDashboard.tsx
@@ -0,0 +1,171 @@
+'use client';
+
+import { useState } from 'react';
+import { RefreshCw, BarChart3 } from 'lucide-react';
+import { ReviewIQFilterProvider, useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
+import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
+import { FilterBar } from './FilterBar';
+import { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton';
+import { SentimentPie } from './charts/SentimentPie';
+import { IntensityHeatmap } from './charts/IntensityHeatmap';
+import { TimelineChart } from './charts/TimelineChart';
+import { IssuesTable } from './tables/IssuesTable';
+import { SpansTable } from './tables/SpansTable';
+import { ExecutiveSummary } from './insights/ExecutiveSummary';
+import { OpportunityMatrix } from './insights/OpportunityMatrix';
+import type { URTDomain } from './types';
+
+interface ReviewIQDashboardProps {
+ jobId?: string | null;
+ businessId?: string | null;
+}
+
+/**
+ * Inner dashboard component that uses the filter context.
+ *
+ * Streamlined flow (no redundancy):
+ * 1. Hero: Executive Summary (rating, AI insights, #1 problem/strength, top complaints)
+ * 2. Explore: Sentiment + Category Heatmap (side by side)
+ * 3. Action: Opportunity Matrix (what to fix)
+ * 4. Trends: Timeline
+ * 5. Deep Dive: Issues & Spans tables
+ */
+function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
+ const { filters, setURTDomain } = useReviewIQFilters();
+ const [issuesPage, setIssuesPage] = useState(1);
+ const [spansPage, setSpansPage] = useState(1);
+
+ const { data, loading, error, refetch } = useReviewIQAnalytics({
+ jobId,
+ businessId,
+ filters,
+ issuesPage,
+ issuesPageSize: 10,
+ spansPage,
+ spansPageSize: 10,
+ });
+
+ const handleIssuesPageChange = (page: number) => setIssuesPage(page);
+ const handleSpansPageChange = (page: number) => setSpansPage(page);
+
+ // No job selected
+ if (!jobId && !businessId) {
+ return ;
+ }
+
+ // Loading state
+ if (loading && !data) {
+ return ;
+ }
+
+ // Error state
+ if (error) {
+ return ;
+ }
+
+ // No data
+ if (!data) {
+ return ;
+ }
+
+ // Handle domain click for filtering
+ const handleDomainClick = (domain: URTDomain) => {
+ setURTDomain(filters.urtDomain === domain ? null : domain);
+ };
+
+ return (
+
+ {/* ═══════════════════════════════════════════════════════════════
+ HEADER
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+
+
+
+
+
ReviewIQ Analytics
+
+ {data.overview.total_reviews.toLocaleString()} reviews • {data.overview.total_spans.toLocaleString()} insights extracted
+
+
+
+
+
+
+ {/* Active Filters Bar */}
+
+
+ {/* ═══════════════════════════════════════════════════════════════
+ SECTION 1: EXECUTIVE SUMMARY (Hero)
+ Rating, AI summary, #1 Problem, #1 Strength, Top Complaints
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+ {/* ═══════════════════════════════════════════════════════════════
+ SECTION 2: EXPLORE (Sentiment + Categories)
+ Side-by-side: How customers feel + What they talk about
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+
+
+
+ {/* ═══════════════════════════════════════════════════════════════
+ SECTION 3: ACTION (Opportunity Matrix)
+ What to fix - prioritized by impact vs effort
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+ {/* ═══════════════════════════════════════════════════════════════
+ SECTION 4: TRENDS (Timeline)
+ How things change over time
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+ {/* ═══════════════════════════════════════════════════════════════
+ SECTION 5: DEEP DIVE (Tables)
+ Detailed issues and individual mentions
+ ═══════════════════════════════════════════════════════════════ */}
+
+
+
+
+
+ {/* Debug Info (dev only) */}
+ {process.env.NODE_ENV === 'development' && (
+
+
+ Debug: Filters Applied
+
+
+ {JSON.stringify(data.filters_applied, null, 2)}
+
+
+ )}
+
+ );
+}
+
+/**
+ * Main ReviewIQ Dashboard with filter context provider.
+ */
+export function ReviewIQDashboard({ jobId, businessId }: ReviewIQDashboardProps) {
+ return (
+
+
+
+ );
+}
diff --git a/web/components/reviewiq/charts/IntensityHeatmap.tsx b/web/components/reviewiq/charts/IntensityHeatmap.tsx
new file mode 100644
index 0000000..e335059
--- /dev/null
+++ b/web/components/reviewiq/charts/IntensityHeatmap.tsx
@@ -0,0 +1,305 @@
+'use client';
+
+import { useMemo } from 'react';
+import { Filter, ThumbsUp, ThumbsDown, TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import type { URTDomainPoint, URTDomain, Sentiment } from '../types';
+import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
+
+interface SentimentHeatmapProps {
+ data: URTDomainPoint[];
+}
+
+// User-friendly domain config with emojis and descriptions
+const DOMAIN_CONFIG: Record = {
+ P: { emoji: '👥', label: 'Staff & Service', description: 'How staff treats customers' },
+ V: { emoji: '💰', label: 'Pricing & Value', description: 'Price, fees, and value for money' },
+ J: { emoji: '⏱️', label: 'Speed & Process', description: 'Wait times and procedures' },
+ O: { emoji: '🛍️', label: 'Product Quality', description: 'Quality of goods/services' },
+ A: { emoji: '📍', label: 'Availability', description: 'Hours, location, accessibility' },
+ E: { emoji: '🏢', label: 'Facilities', description: 'Cleanliness, comfort, ambiance' },
+ R: { emoji: '🤝', label: 'Trust & Ethics', description: 'Honesty, reliability, fairness' },
+};
+
+// Ordered domains by typical business priority
+const DOMAIN_ORDER = ['P', 'V', 'J', 'O', 'A', 'E', 'R'];
+
+// Color scales
+const getPositiveColor = (value: number, max: number): string => {
+ if (max === 0 || value === 0) return '#f3f4f6';
+ const intensity = value / max;
+ if (intensity < 0.25) return '#dcfce7'; // Light green
+ if (intensity < 0.5) return '#86efac';
+ if (intensity < 0.75) return '#22c55e';
+ return '#15803d'; // Dark green
+};
+
+const getNegativeColor = (value: number, max: number): string => {
+ if (max === 0 || value === 0) return '#f3f4f6';
+ const intensity = value / max;
+ if (intensity < 0.25) return '#fee2e2'; // Light red
+ if (intensity < 0.5) return '#fca5a5';
+ if (intensity < 0.75) return '#ef4444';
+ return '#b91c1c'; // Dark red
+};
+
+/**
+ * Sentiment Heatmap - Shows Praise vs Complaints by Domain.
+ * User-friendly design with emojis and clear labels.
+ * Click to filter by domain and sentiment.
+ */
+export function IntensityHeatmap({ data }: SentimentHeatmapProps) {
+ const { filters, setURTDomain, toggleSentiment } = useReviewIQFilters();
+
+ // Check if cross-filters are active
+ const hasSentimentFilter = filters.sentiment.length > 0;
+ const hasDomainFilter = filters.urtDomain !== null;
+ const hasCrossFilter = hasSentimentFilter || hasDomainFilter;
+
+ // Process and sort data
+ const processedData = useMemo(() => {
+ // Create lookup map
+ const lookup = new Map();
+ let maxPositive = 0;
+ let maxNegative = 0;
+
+ data.forEach((d) => {
+ lookup.set(d.domain, d);
+ if (d.positive_count > maxPositive) maxPositive = d.positive_count;
+ if (d.negative_count > maxNegative) maxNegative = d.negative_count;
+ });
+
+ // Sort by domain order, then build rows
+ const rows = DOMAIN_ORDER
+ .filter(domain => lookup.has(domain))
+ .map(domain => {
+ const d = lookup.get(domain)!;
+ const total = d.positive_count + d.negative_count + d.neutral_count;
+ const positiveRatio = total > 0 ? d.positive_count / total : 0;
+ const negativeRatio = total > 0 ? d.negative_count / total : 0;
+
+ // Determine trend indicator
+ let trend: 'up' | 'down' | 'neutral' = 'neutral';
+ if (positiveRatio > 0.6) trend = 'up';
+ else if (negativeRatio > 0.4) trend = 'down';
+
+ return {
+ domain: d.domain,
+ config: DOMAIN_CONFIG[d.domain] || {
+ emoji: '📊',
+ label: d.domain_name || d.domain,
+ description: ''
+ },
+ positive: d.positive_count,
+ negative: d.negative_count,
+ total,
+ positiveRatio,
+ negativeRatio,
+ trend,
+ };
+ });
+
+ return { rows, maxPositive, maxNegative };
+ }, [data]);
+
+ const handleCellClick = (domain: string, sentiment: 'positive' | 'negative') => {
+ setURTDomain(domain as URTDomain);
+ // Clear other sentiments and set the clicked one
+ if (!filters.sentiment.includes(sentiment)) {
+ toggleSentiment(sentiment);
+ }
+ };
+
+ const handleDomainClick = (domain: string) => {
+ setURTDomain(domain as URTDomain);
+ };
+
+ return (
+
+
+
+
Feedback by Category
+
Click any cell to filter reviews
+
+ {hasCrossFilter && (
+
+
+ Filtered
+
+ )}
+
+
+ {data.length === 0 ? (
+
+ No feedback data available
+
+ ) : (
+
+
+
+
+ |
+ Category
+ |
+
+
+
+ Praise
+
+ |
+
+
+
+ Complaints
+
+ |
+
+ Health
+ |
+
+
+
+ {processedData.rows.map((row) => {
+ const isDomainActive = filters.urtDomain === row.domain;
+ const isPositiveActive = isDomainActive && filters.sentiment.includes('positive');
+ const isNegativeActive = isDomainActive && filters.sentiment.includes('negative');
+
+ return (
+
+ {/* Domain Label */}
+ |
+
+ |
+
+ {/* Praise Cell */}
+
+
+ |
+
+ {/* Complaints Cell */}
+
+
+ |
+
+ {/* Health Indicator */}
+
+
+ {row.trend === 'up' && (
+
+
+
+ )}
+ {row.trend === 'down' && (
+
+
+
+ )}
+ {row.trend === 'neutral' && (
+
+
+
+ )}
+
+ |
+
+ );
+ })}
+
+
+
+ {/* Legend */}
+
+
+
+ = needs attention
+
+
+
+ )}
+
+ );
+}
diff --git a/web/components/reviewiq/charts/SentimentPie.tsx b/web/components/reviewiq/charts/SentimentPie.tsx
new file mode 100644
index 0000000..f4228e0
--- /dev/null
+++ b/web/components/reviewiq/charts/SentimentPie.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import { useMemo } from 'react';
+import { X, Filter, Smile, Frown, Meh, AlertTriangle } from 'lucide-react';
+import type { SentimentDataPoint, Sentiment } from '../types';
+import { DOMAIN_LABELS } from '../types';
+import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
+
+interface SentimentPieProps {
+ data: SentimentDataPoint[];
+}
+
+// User-friendly sentiment config
+const SENTIMENT_CONFIG: Record = {
+ 'V+': {
+ emoji: '😊',
+ icon: Smile,
+ label: 'Happy',
+ description: 'Positive experiences',
+ color: '#22c55e',
+ bgColor: '#dcfce7',
+ borderColor: '#86efac',
+ },
+ 'V-': {
+ emoji: '😟',
+ icon: Frown,
+ label: 'Unhappy',
+ description: 'Negative experiences',
+ color: '#ef4444',
+ bgColor: '#fee2e2',
+ borderColor: '#fca5a5',
+ },
+ 'V0': {
+ emoji: '😐',
+ icon: Meh,
+ label: 'Neutral',
+ description: 'Factual mentions',
+ color: '#eab308',
+ bgColor: '#fef9c3',
+ borderColor: '#fde047',
+ },
+ 'V±': {
+ emoji: '🤔',
+ icon: AlertTriangle,
+ label: 'Mixed',
+ description: 'Both good & bad',
+ color: '#f97316',
+ bgColor: '#ffedd5',
+ borderColor: '#fdba74',
+ },
+};
+
+// Map valence codes to sentiment filter values
+const valenceToSentiment: Record = {
+ 'V+': 'positive',
+ 'V0': 'neutral',
+ 'V-': 'negative',
+ 'V±': 'negative', // Mixed is treated as negative for filtering
+};
+
+// Display order
+const SENTIMENT_ORDER = ['V+', 'V-', 'V0', 'V±'];
+
+/**
+ * Sentiment Overview - Visual cards showing how customers feel.
+ * User-friendly design with emojis and clear numbers.
+ * Click to filter by sentiment.
+ */
+export function SentimentPie({ data }: SentimentPieProps) {
+ const { filters, toggleSentiment } = useReviewIQFilters();
+
+ // Process data
+ const processedData = useMemo(() => {
+ const lookup = new Map();
+ let totalReviews = 0;
+ let totalMentions = 0;
+
+ data.forEach((d) => {
+ lookup.set(d.valence, d);
+ totalReviews += d.review_count;
+ totalMentions += d.count;
+ });
+
+ // Build cards in order
+ const cards = SENTIMENT_ORDER
+ .filter(valence => lookup.has(valence))
+ .map(valence => {
+ const d = lookup.get(valence)!;
+ const config = SENTIMENT_CONFIG[valence];
+
+ return {
+ valence,
+ config,
+ reviewCount: d.review_count,
+ mentionCount: d.count,
+ percentage: d.percentage,
+ };
+ });
+
+ // Calculate overall sentiment score (0-100)
+ const positive = lookup.get('V+');
+ const negative = lookup.get('V-');
+ const posCount = positive?.review_count || 0;
+ const negCount = negative?.review_count || 0;
+ const sentimentScore = totalReviews > 0
+ ? Math.round(((posCount - negCount) / totalReviews + 1) * 50)
+ : 50;
+
+ return { cards, totalReviews, totalMentions, sentimentScore };
+ }, [data]);
+
+ const handleClick = (valence: string) => {
+ const sentiment = valenceToSentiment[valence];
+ if (sentiment) {
+ toggleSentiment(sentiment);
+ }
+ };
+
+ const isFiltering = filters.sentiment.length > 0;
+ const hasDomainFilter = filters.urtDomain !== null;
+
+ // Determine sentiment indicator color
+ const getScoreColor = () => {
+ if (processedData.sentimentScore >= 60) return 'text-green-600';
+ if (processedData.sentimentScore >= 40) return 'text-yellow-600';
+ return 'text-red-600';
+ };
+
+ const getScoreEmoji = () => {
+ if (processedData.sentimentScore >= 70) return '🎉';
+ if (processedData.sentimentScore >= 55) return '👍';
+ if (processedData.sentimentScore >= 45) return '😐';
+ if (processedData.sentimentScore >= 30) return '😕';
+ return '😰';
+ };
+
+ return (
+
+
+
+
How Customers Feel
+
+ {processedData.totalReviews.toLocaleString()} reviews analyzed
+
+
+
+ {hasDomainFilter && !isFiltering && (
+
+
+ {DOMAIN_LABELS[filters.urtDomain!]}
+
+ )}
+ {isFiltering && (
+
+
+ {filters.sentiment.join(', ')}
+
+
+
+ )}
+
+
+
+ {processedData.cards.length === 0 ? (
+
+ No sentiment data available
+
+ ) : (
+ <>
+ {/* Overall Sentiment Score */}
+
+
+
{getScoreEmoji()}
+
+ {processedData.sentimentScore}%
+
+
Sentiment Score
+
+
+
+ {/* Sentiment Cards Grid */}
+
+ {processedData.cards.map((card) => {
+ const sentiment = valenceToSentiment[card.valence];
+ const isActive = sentiment && filters.sentiment.includes(sentiment);
+ const Icon = card.config.icon;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Tip */}
+
+ Click any card to filter reviews by sentiment
+
+ >
+ )}
+
+ );
+}
diff --git a/web/components/reviewiq/charts/TimelineChart.tsx b/web/components/reviewiq/charts/TimelineChart.tsx
new file mode 100644
index 0000000..ec05b05
--- /dev/null
+++ b/web/components/reviewiq/charts/TimelineChart.tsx
@@ -0,0 +1,488 @@
+'use client';
+
+import { useState, useMemo } from 'react';
+import {
+ ComposedChart,
+ Bar,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Brush,
+ Area,
+ ReferenceLine,
+} from 'recharts';
+import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
+import type { TimelinePoint, TimeRange } from '../types';
+import { DOMAIN_LABELS } from '../types';
+import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
+
+interface TimelineChartProps {
+ data: TimelinePoint[];
+}
+
+type ViewMode = 'sentiment' | 'volume' | 'rating';
+
+const VIEW_OPTIONS: { value: ViewMode; emoji: string; label: string; description: string }[] = [
+ { value: 'sentiment', emoji: '😊', label: 'Sentiment', description: 'Positive vs Negative over time' },
+ { value: 'volume', emoji: '📊', label: 'Volume', description: 'Review & mention counts' },
+ { value: 'rating', emoji: '⭐', label: 'Rating', description: 'Average rating trend' },
+];
+
+const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string }[] = [
+ { value: '7d', label: '7D', description: 'Last 7 days' },
+ { value: '14d', label: '2W', description: 'Last 2 weeks' },
+ { value: '30d', label: '1M', description: 'Last month' },
+ { value: '90d', label: '3M', description: 'Last 3 months' },
+ { value: '1y', label: '1Y', description: 'Last year' },
+ { value: 'all', label: 'All', description: 'All time' },
+];
+
+/**
+ * Timeline Chart - Shows trends over time.
+ * User-friendly design with view toggles and interactive brush.
+ * Responds to domain/sentiment filters.
+ */
+export function TimelineChart({ data }: TimelineChartProps) {
+ const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
+ const [viewMode, setViewMode] = useState('sentiment');
+ const [localBrushRange, setLocalBrushRange] = useState<{
+ startIndex: number;
+ endIndex: number;
+ } | null>(null);
+
+ // Sort data chronologically (oldest first, most recent on right)
+ const sortedData = useMemo(() => {
+ return [...data].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
+ }, [data]);
+
+ // Calculate summary stats
+ const stats = useMemo(() => {
+ if (sortedData.length === 0) return null;
+
+ const totalReviews = sortedData.reduce((sum, d) => sum + d.review_count, 0);
+ const totalPositive = sortedData.reduce((sum, d) => sum + d.positive_count, 0);
+ const totalNegative = sortedData.reduce((sum, d) => sum + d.negative_count, 0);
+ const avgRating = sortedData.reduce((sum, d) => sum + (d.avg_rating || 0), 0) / sortedData.length;
+
+ // Calculate trend (compare last 30% vs first 30%)
+ const splitPoint = Math.floor(sortedData.length * 0.3);
+ const earlyData = sortedData.slice(0, splitPoint);
+ const recentData = sortedData.slice(-splitPoint);
+
+ const earlyPositiveRatio = earlyData.length > 0
+ ? earlyData.reduce((sum, d) => sum + d.positive_count, 0) /
+ Math.max(1, earlyData.reduce((sum, d) => sum + d.positive_count + d.negative_count, 0))
+ : 0;
+ const recentPositiveRatio = recentData.length > 0
+ ? recentData.reduce((sum, d) => sum + d.positive_count, 0) /
+ Math.max(1, recentData.reduce((sum, d) => sum + d.positive_count + d.negative_count, 0))
+ : 0;
+
+ const trendDirection = recentPositiveRatio > earlyPositiveRatio + 0.05
+ ? 'improving'
+ : recentPositiveRatio < earlyPositiveRatio - 0.05
+ ? 'declining'
+ : 'stable';
+
+ return {
+ totalReviews,
+ totalPositive,
+ totalNegative,
+ avgRating,
+ positiveRatio: totalReviews > 0 ? (totalPositive / (totalPositive + totalNegative)) * 100 : 0,
+ trendDirection,
+ dateRange: {
+ start: sortedData[0]?.date,
+ end: sortedData[sortedData.length - 1]?.date,
+ },
+ };
+ }, [sortedData]);
+
+ // Handle brush change
+ const handleBrushChange = (range: { startIndex?: number; endIndex?: number }) => {
+ if (
+ range &&
+ typeof range.startIndex === 'number' &&
+ typeof range.endIndex === 'number'
+ ) {
+ setLocalBrushRange({ startIndex: range.startIndex, endIndex: range.endIndex });
+
+ // Only set filter if not full range
+ if (range.startIndex !== 0 || range.endIndex !== sortedData.length - 1) {
+ const startDate = sortedData[range.startIndex]?.date;
+ const endDate = sortedData[range.endIndex]?.date;
+ if (startDate && endDate) {
+ setBrushRange({ start: startDate, end: endDate });
+ }
+ } else {
+ setBrushRange(null);
+ }
+ }
+ };
+
+ const clearBrushRange = () => {
+ setLocalBrushRange({ startIndex: 0, endIndex: sortedData.length - 1 });
+ setBrushRange(null);
+ };
+
+ const hasBrushFilter = filters.brushRange !== null;
+ const hasDomainFilter = filters.urtDomain !== null;
+ const hasSentimentFilter = filters.sentiment.length > 0;
+ const hasAnyFilter = hasBrushFilter || hasDomainFilter || hasSentimentFilter;
+
+ // Format date for display
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
Trends Over Time
+
+ {stats ? (
+ <>
+ {stats.totalReviews.toLocaleString()} reviews
+ {stats.dateRange.start && stats.dateRange.end && (
+
+ {' '}• {formatDate(stats.dateRange.start)} - {formatDate(stats.dateRange.end)}
+
+ )}
+ >
+ ) : (
+ 'No data available'
+ )}
+
+
+
+
+ {/* Controls */}
+
+ {/* Active filters indicator */}
+ {(hasDomainFilter || hasSentimentFilter) && (
+
+
+ {hasDomainFilter && DOMAIN_LABELS[filters.urtDomain!]}
+ {hasDomainFilter && hasSentimentFilter && ' + '}
+ {hasSentimentFilter && filters.sentiment.join(', ')}
+
+ )}
+
+ {/* Clear brush button */}
+ {hasBrushFilter && (
+
+ )}
+
+ {/* Time range buttons */}
+
+ {TIME_RANGE_OPTIONS.map((opt) => (
+
+ ))}
+
+
+
+
+ {/* View Mode Toggle */}
+
+ {VIEW_OPTIONS.map((opt) => (
+
+ ))}
+
+ {/* Trend indicator */}
+ {stats && (
+
+
Trend:
+ {stats.trendDirection === 'improving' && (
+
+
+ Improving
+
+ )}
+ {stats.trendDirection === 'declining' && (
+
+
+ Declining
+
+ )}
+ {stats.trendDirection === 'stable' && (
+
+
+ Stable
+
+ )}
+
+ )}
+
+
+ {sortedData.length === 0 ? (
+
+
+ No timeline data available
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Sentiment View */}
+ {viewMode === 'sentiment' && (
+ <>
+
+
+
+ >
+ )}
+
+ {/* Volume View */}
+ {viewMode === 'volume' && (
+ <>
+
+
+
+ >
+ )}
+
+ {/* Rating View */}
+ {viewMode === 'rating' && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {
+ if (active && payload && payload.length) {
+ const data = payload[0]?.payload as TimelinePoint;
+ return (
+
+
+ 📅 {formatDate(String(label))}
+
+
+ {viewMode === 'sentiment' && (
+ <>
+
+ 😊 Positive
+ {data.positive_count}
+
+
+ 😟 Negative
+ {data.negative_count}
+
+ >
+ )}
+
+ {viewMode === 'volume' && (
+ <>
+
+ 📝 Reviews
+ {data.review_count}
+
+
+ 💬 Mentions
+ {data.span_count}
+
+ >
+ )}
+
+ {viewMode === 'rating' && (
+
+ ⭐ Avg Rating
+
+ {data.avg_rating?.toFixed(1) || 'N/A'}
+
+
+ )}
+
+
+ {data.review_count} reviews this period
+
+
+ );
+ }
+ return null;
+ }}
+ />
+
+ {/* Brush for date range selection */}
+
+
+
+ )}
+
+ {/* Footer hint */}
+
+ 💡 Drag the handles below the chart to zoom into a specific date range
+
+
+ );
+}
diff --git a/web/components/reviewiq/charts/URTBarChart.tsx b/web/components/reviewiq/charts/URTBarChart.tsx
new file mode 100644
index 0000000..40a15f5
--- /dev/null
+++ b/web/components/reviewiq/charts/URTBarChart.tsx
@@ -0,0 +1,238 @@
+'use client';
+
+import { useMemo } from 'react';
+import { X, Filter, TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import type { URTDomainPoint, URTDomain } from '../types';
+import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
+
+interface URTBarChartProps {
+ data: URTDomainPoint[];
+}
+
+// User-friendly domain config with emojis and descriptions
+const DOMAIN_CONFIG: Record = {
+ P: { emoji: '👥', label: 'Staff & Service', color: '#3b82f6', bgColor: '#dbeafe' },
+ V: { emoji: '💰', label: 'Pricing & Value', color: '#ec4899', bgColor: '#fce7f3' },
+ J: { emoji: '⏱️', label: 'Speed & Process', color: '#8b5cf6', bgColor: '#ede9fe' },
+ O: { emoji: '🛍️', label: 'Product Quality', color: '#f97316', bgColor: '#ffedd5' },
+ A: { emoji: '📍', label: 'Availability', color: '#10b981', bgColor: '#d1fae5' },
+ E: { emoji: '🏢', label: 'Facilities', color: '#06b6d4', bgColor: '#cffafe' },
+ R: { emoji: '🤝', label: 'Trust & Ethics', color: '#f59e0b', bgColor: '#fef3c7' },
+};
+
+// Ordered domains by typical business priority
+const DOMAIN_ORDER = ['P', 'V', 'J', 'O', 'A', 'E', 'R'];
+
+/**
+ * Domain Distribution - Horizontal bar chart showing what customers talk about.
+ * User-friendly design with emojis and clear progress bars.
+ * Click to filter by domain.
+ */
+export function URTBarChart({ data }: URTBarChartProps) {
+ const { filters, setURTDomain } = useReviewIQFilters();
+
+ // Process and sort data
+ const processedData = useMemo(() => {
+ const lookup = new Map();
+ let maxCount = 0;
+ let totalMentions = 0;
+
+ data.forEach((d) => {
+ lookup.set(d.domain, d);
+ if (d.count > maxCount) maxCount = d.count;
+ totalMentions += d.count;
+ });
+
+ // Sort by domain order, then build rows
+ const rows = DOMAIN_ORDER
+ .filter(domain => lookup.has(domain))
+ .map(domain => {
+ const d = lookup.get(domain)!;
+ const config = DOMAIN_CONFIG[d.domain] || {
+ emoji: '📊',
+ label: d.domain_name || d.domain,
+ color: '#6b7280',
+ bgColor: '#f3f4f6',
+ };
+
+ const percentage = totalMentions > 0 ? (d.count / totalMentions) * 100 : 0;
+ const barWidth = maxCount > 0 ? (d.count / maxCount) * 100 : 0;
+
+ // Health indicator based on positive/negative ratio
+ const total = d.positive_count + d.negative_count + d.neutral_count;
+ const positiveRatio = total > 0 ? d.positive_count / total : 0;
+ const negativeRatio = total > 0 ? d.negative_count / total : 0;
+
+ let health: 'good' | 'warning' | 'critical' = 'warning';
+ if (positiveRatio > 0.6) health = 'good';
+ else if (negativeRatio > 0.5) health = 'critical';
+
+ return {
+ domain: d.domain,
+ config,
+ count: d.count,
+ reviewCount: d.review_count,
+ percentage,
+ barWidth,
+ health,
+ positiveCount: d.positive_count,
+ negativeCount: d.negative_count,
+ };
+ })
+ // Sort by count descending
+ .sort((a, b) => b.count - a.count);
+
+ return { rows, maxCount, totalMentions };
+ }, [data]);
+
+ const handleClick = (domain: string) => {
+ setURTDomain(filters.urtDomain === domain ? null : domain as URTDomain);
+ };
+
+ const isFiltering = filters.urtDomain !== null;
+ const hasSentimentFilter = filters.sentiment.length > 0;
+
+ return (
+
+
+
+
What Customers Talk About
+
+ {processedData.totalMentions.toLocaleString()} total mentions
+
+
+
+ {hasSentimentFilter && !isFiltering && (
+
+
+ {filters.sentiment.join(', ')}
+
+ )}
+ {isFiltering && (
+ <>
+
+ {DOMAIN_CONFIG[filters.urtDomain!]?.label || filters.urtDomain}
+
+
+ >
+ )}
+
+
+
+ {processedData.rows.length === 0 ? (
+
+ No data available
+
+ ) : (
+
+ {processedData.rows.map((row) => {
+ const isActive = filters.urtDomain === row.domain;
+
+ return (
+
+ );
+ })}
+
+ )}
+
+ {/* Legend */}
+
+
+ Mostly positive
+
+
+ Mixed
+
+
+ Needs attention
+
+
+
+ );
+}
diff --git a/web/components/reviewiq/insights/ExecutiveSummary.tsx b/web/components/reviewiq/insights/ExecutiveSummary.tsx
new file mode 100644
index 0000000..1de65cc
--- /dev/null
+++ b/web/components/reviewiq/insights/ExecutiveSummary.tsx
@@ -0,0 +1,432 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ Sparkles,
+ TrendingUp,
+ TrendingDown,
+ Languages,
+ Loader2,
+ Star,
+ Target,
+ AlertTriangle,
+ CheckCircle2,
+ ChevronRight,
+ Zap,
+ Award,
+} from 'lucide-react';
+import { useTranslation } from '@/hooks/useTranslation';
+import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
+import { getSubcodeDefinition } from '@/lib/taxonomy/data';
+
+interface ExecutiveSummaryProps {
+ insights: Insights;
+ avgRating: number | null;
+ domainScores?: DomainScore[];
+ onDriverClick?: (subcode: string) => void;
+ onDomainClick?: (domain: URTDomain) => void;
+}
+
+// User-friendly domain config
+const DOMAIN_CONFIG: Record = {
+ P: { emoji: '👥', label: 'Staff & Service' },
+ V: { emoji: '💰', label: 'Pricing & Value' },
+ J: { emoji: '⏱️', label: 'Speed & Process' },
+ O: { emoji: '🛍️', label: 'Product Quality' },
+ A: { emoji: '📍', label: 'Availability' },
+ E: { emoji: '🏢', label: 'Facilities' },
+ R: { emoji: '🤝', label: 'Trust & Ethics' },
+};
+
+// Get rating emoji and label
+const getRatingDisplay = (rating: number | null) => {
+ if (!rating) return { emoji: '❓', label: 'No rating', color: 'text-gray-500' };
+ if (rating >= 4.5) return { emoji: '🌟', label: 'Excellent', color: 'text-green-600' };
+ if (rating >= 4.0) return { emoji: '😊', label: 'Good', color: 'text-green-500' };
+ if (rating >= 3.5) return { emoji: '🙂', label: 'Average', color: 'text-yellow-600' };
+ if (rating >= 3.0) return { emoji: '😐', label: 'Fair', color: 'text-orange-500' };
+ return { emoji: '😟', label: 'Needs Work', color: 'text-red-500' };
+};
+
+// Domain complaints section
+function TopComplaintsSection({
+ domainScores,
+ weaknesses,
+ opportunityMatrix,
+ onDomainClick,
+}: {
+ domainScores: DomainScore[];
+ weaknesses: WeaknessItem[];
+ opportunityMatrix: OpportunityMatrix | null;
+ onDomainClick?: (domain: URTDomain) => void;
+}) {
+ const { translate, getState } = useTranslation('en');
+
+ // Get example quote for a domain
+ const getQuoteForDomain = (domainKey: string): OpportunitySpan | null => {
+ const weakness = weaknesses.find(w => w.domain === domainKey);
+ if (weakness?.example_spans?.length) {
+ return weakness.example_spans[0];
+ }
+
+ if (opportunityMatrix) {
+ const allOpportunities = [
+ ...opportunityMatrix.quick_wins,
+ ...opportunityMatrix.critical,
+ ...opportunityMatrix.nice_to_have,
+ ...opportunityMatrix.strategic,
+ ];
+ const opportunity = allOpportunities.find(o => o.domain === domainKey && o.spans?.length);
+ if (opportunity?.spans?.length) {
+ return opportunity.spans[0];
+ }
+ }
+
+ return null;
+ };
+
+ // Calculate and sort by negative percentage
+ const sorted = domainScores
+ .map(d => ({
+ ...d,
+ negativePercent: d.total_count > 0
+ ? Math.round((d.negative_count / d.total_count) * 100)
+ : 0,
+ quote: getQuoteForDomain(d.domain),
+ config: DOMAIN_CONFIG[d.domain] || { emoji: '📊', label: d.name },
+ }))
+ .sort((a, b) => b.negativePercent - a.negativePercent)
+ .slice(0, 4);
+
+ const handleTranslate = (e: React.MouseEvent, text: string, id: string) => {
+ e.stopPropagation();
+ translate(text, id);
+ };
+
+ return (
+
+ {sorted.map((domain) => {
+ const quoteId = `domain-${domain.domain}`;
+ const translationState = getState(quoteId);
+ const displayText = translationState.isTranslated && domain.quote
+ ? translationState.translated
+ : domain.quote?.span_text;
+
+ const severity = domain.negativePercent >= 40 ? 'critical' :
+ domain.negativePercent >= 25 ? 'warning' : 'ok';
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export function ExecutiveSummary({
+ insights,
+ avgRating,
+ domainScores,
+ onDriverClick,
+ onDomainClick,
+}: ExecutiveSummaryProps) {
+ const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
+ const [showFullSummary, setShowFullSummary] = useState(false);
+
+ const topStrength = strengths[0];
+ const topWeakness = weaknesses[0];
+ const ratingDisplay = getRatingDisplay(avgRating);
+
+ // Calculate potential rating improvement
+ const potentialRating = rating_simulator?.if_fix_top_3 ||
+ (avgRating && topWeakness?.projected_rating_impact
+ ? avgRating + topWeakness.projected_rating_impact
+ : null);
+
+ // If no insights, show minimal summary
+ if (!executive_summary && !topStrength && !topWeakness) {
+ return (
+
+
+
+
+
+
+
Executive Summary
+
AI-powered insights from your reviews
+
+
+
+
📊
+
+
More data needed
+
+ Continue collecting reviews to unlock actionable insights and recommendations.
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
Executive Summary
+
AI-powered insights from your reviews
+
+
+
+ {/* Rating Badge */}
+ {avgRating && (
+
+
+
{ratingDisplay.emoji}
+
+
+
+
+
+ {avgRating.toFixed(1)}
+
+
+
{ratingDisplay.label}
+
+ {potentialRating && potentialRating > avgRating && (
+
+
Potential
+
+
+
+ {potentialRating.toFixed(1)}
+
+
+
+ )}
+
+ )}
+
+
+
+ {/* AI Summary */}
+ {executive_summary && (
+
+
+
+
💡
+
+
+ {executive_summary}
+
+ {executive_summary.length > 150 && (
+
setShowFullSummary(!showFullSummary)}
+ className="text-blue-600 text-sm font-medium mt-1 hover:underline"
+ >
+ {showFullSummary ? 'Show less' : 'Read more'}
+
+ )}
+
+
+
+
+ )}
+
+ {/* Key Metrics Cards */}
+
+
+ {/* Top Problem */}
+ {topWeakness && (
+
onDriverClick?.(topWeakness.subcode)}
+ className="group p-4 bg-white rounded-xl border-2 border-red-100 hover:border-red-300 hover:shadow-lg transition-all text-left"
+ >
+
+
+
+
+
+ 🔥 #1 Problem
+
+
+
+ {topWeakness.subcode_name}
+
+
+ {getSubcodeDefinition(topWeakness.subcode)}
+
+
+
+ {topWeakness.negative_percentage.toFixed(0)}% negative
+
+
+ {topWeakness.span_count} mentions
+
+
+
+ {topWeakness.projected_rating_impact && (
+
+
If fixed
+
+
+
+ +{topWeakness.projected_rating_impact.toFixed(2)}
+
+
+
+ )}
+
+
+ )}
+
+ {/* Top Strength */}
+ {topStrength && (
+
onDriverClick?.(topStrength.subcode)}
+ className="group p-4 bg-white rounded-xl border-2 border-green-100 hover:border-green-300 hover:shadow-lg transition-all text-left"
+ >
+
+
+
+
+
+ ⭐ #1 Strength
+
+
+
+ {topStrength.subcode_name}
+
+
+ {getSubcodeDefinition(topStrength.subcode)}
+
+
+
+ {topStrength.positive_percentage.toFixed(0)}% positive
+
+
+ {topStrength.span_count} mentions
+
+
+
+
+
+
+ )}
+
+
+
+ {/* Complaint Breakdown */}
+ {domainScores && domainScores.length > 0 && (
+
+
+
+ Where Customers Complain Most
+
+
+
+ )}
+
+ {/* Quick Actions Footer */}
+
+
+
+ 💡 Click any card to drill down into details
+
+
+ Powered by AI
+
+
+
+
+
+ );
+}