Files
whyrating-engine-legacy/web/components/reviewiq/charts/SentimentPie.tsx
Alejandro Gutiérrez 8f9dd136cd feat(reviewiq): Redesign dashboard with user-friendly UX
- ExecutiveSummary: Add rating badge with emoji, AI narrative section,
  #1 Problem/#1 Strength cards, domain complaints with progress bars
- SentimentPie: Replace pie chart with card-based design showing
  sentiment score, emoji indicators (😊😟😐🤔), percentages
- IntensityHeatmap: Transform to Praise vs Complaints heatmap with
  friendly domain labels (👥 Staff, 💰 Pricing, etc.)
- URTBarChart: Horizontal progress bars with emojis, health indicators
- TimelineChart: Add view toggles (Sentiment/Volume/Rating), trend
  indicator, fix chronological order (oldest→newest left→right)
- ReviewIQDashboard: Streamline from 11 sections to 5, remove redundancy

Removed redundant components:
- DomainScores (merged into ExecutiveSummary)
- KPISection (stats in header)
- RatingSimulator (in ExecutiveSummary)
- StrengthsWeaknesses (in ExecutiveSummary)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 02:52:13 +00:00

274 lines
8.7 KiB
TypeScript

'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<string, {
emoji: string;
icon: typeof Smile;
label: string;
description: string;
color: string;
bgColor: string;
borderColor: string;
}> = {
'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<string, Sentiment | null> = {
'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<string, SentimentDataPoint>();
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 (
<div
className={`bg-white rounded-xl p-6 shadow-md transition-all ${
isFiltering
? 'border-3 border-blue-500 ring-2 ring-blue-200'
: hasDomainFilter
? 'border-2 border-purple-400 ring-1 ring-purple-200'
: 'border-2 border-gray-300 hover:border-blue-400'
}`}
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-bold text-gray-900">How Customers Feel</h3>
<p className="text-sm text-gray-500 mt-1">
{processedData.totalReviews.toLocaleString()} reviews analyzed
</p>
</div>
<div className="flex items-center gap-2">
{hasDomainFilter && !isFiltering && (
<span className="flex items-center gap-1 text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full">
<Filter className="w-3 h-3" />
{DOMAIN_LABELS[filters.urtDomain!]}
</span>
)}
{isFiltering && (
<div className="flex items-center gap-2">
<span className="text-xs text-blue-700 font-medium">
{filters.sentiment.join(', ')}
</span>
<button
onClick={() => {
filters.sentiment.forEach((s) => toggleSentiment(s));
}}
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>
</div>
{processedData.cards.length === 0 ? (
<div className="flex items-center justify-center h-64 text-gray-500">
No sentiment data available
</div>
) : (
<>
{/* Overall Sentiment Score */}
<div className="flex items-center justify-center mb-4 p-3 bg-gray-50 rounded-lg">
<div className="text-center">
<div className="text-3xl mb-1">{getScoreEmoji()}</div>
<div className={`text-2xl font-bold ${getScoreColor()}`}>
{processedData.sentimentScore}%
</div>
<div className="text-xs text-gray-500">Sentiment Score</div>
</div>
</div>
{/* Sentiment Cards Grid */}
<div className="grid grid-cols-2 gap-3">
{processedData.cards.map((card) => {
const sentiment = valenceToSentiment[card.valence];
const isActive = sentiment && filters.sentiment.includes(sentiment);
const Icon = card.config.icon;
return (
<button
key={card.valence}
onClick={() => handleClick(card.valence)}
className={`p-3 rounded-xl transition-all text-left ${
isActive
? 'ring-2 ring-blue-500 ring-offset-2'
: 'hover:scale-105 hover:shadow-md'
}`}
style={{
backgroundColor: card.config.bgColor,
borderWidth: '2px',
borderColor: card.config.borderColor,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="text-2xl mb-1">{card.config.emoji}</div>
<div
className="font-bold text-sm"
style={{ color: card.config.color }}
>
{card.config.label}
</div>
</div>
<div className="text-right">
<div
className="text-xl font-bold"
style={{ color: card.config.color }}
>
{card.percentage.toFixed(0)}%
</div>
<div className="text-xs text-gray-600">
{card.reviewCount} reviews
</div>
</div>
</div>
{/* Mini progress bar */}
<div className="mt-2 h-1.5 bg-white/50 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${card.percentage}%`,
backgroundColor: card.config.color,
}}
/>
</div>
</button>
);
})}
</div>
{/* Tip */}
<div className="mt-4 pt-3 border-t border-gray-100 text-center text-xs text-gray-500">
Click any card to filter reviews by sentiment
</div>
</>
)}
</div>
);
}