- 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>
274 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|