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>
This commit is contained in:
273
web/components/reviewiq/charts/SentimentPie.tsx
Normal file
273
web/components/reviewiq/charts/SentimentPie.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user