feat(frontend): Add BusinessReport component for 6-section €60 report
- Create BusinessReport.tsx with 6 sections: 1. Executive Summary (health score, rating, momentum) 2. Risk Scorecard (indicators with colors/trends) 3. Critical Issues (evidence, solutions, timelines) 4. Strengths to Protect (quotes, leverage actions) 5. Action Matrix (effort/impact quadrants) 6. 90-Day Tracking (KPI targets table) - Update types.ts with new interfaces: - SynthesisV2 for new report format - LegacySynthesis for backwards compatibility - Type guard isSynthesisV2() for runtime detection - Update ReportTab to auto-detect synthesis version - Update AnalystReport, ReviewIQDashboard, StoryView for backwards compatibility with union type Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
587
web/components/reviewiq/AnalystReport.tsx
Normal file
587
web/components/reviewiq/AnalystReport.tsx
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Star, TrendingUp, TrendingDown, Minus, AlertTriangle, CheckCircle, Quote, ArrowRight, Zap, Clock, Target, Users, MessageSquare, Trophy, Megaphone } from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
LegacySynthesis,
|
||||||
|
SentimentDataPoint,
|
||||||
|
URTDomainPoint,
|
||||||
|
TimelinePoint,
|
||||||
|
OverviewStats,
|
||||||
|
ReportAction,
|
||||||
|
ReportEvidence,
|
||||||
|
ReportStrength,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Domain config
|
||||||
|
const DOMAINS: Record<string, { emoji: string; label: string }> = {
|
||||||
|
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' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AnalystReportProps {
|
||||||
|
synthesis: LegacySynthesis;
|
||||||
|
overview: OverviewStats;
|
||||||
|
sentiment: SentimentDataPoint[];
|
||||||
|
domains: URTDomainPoint[];
|
||||||
|
timeline: TimelinePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Analyst Report - A consultant-quality business narrative.
|
||||||
|
* Replaces widget soup with a flowing, story-driven report.
|
||||||
|
*/
|
||||||
|
export function AnalystReport({
|
||||||
|
synthesis,
|
||||||
|
overview,
|
||||||
|
sentiment,
|
||||||
|
domains,
|
||||||
|
timeline
|
||||||
|
}: AnalystReportProps) {
|
||||||
|
const positivePct = sentiment.find(s => s.valence === 'V+')?.percentage || 0;
|
||||||
|
const negativePct = sentiment.find(s => s.valence === 'V-')?.percentage || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE VERDICT
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
<section className="bg-gradient-to-br from-slate-900 to-slate-800 rounded-2xl p-8 text-white">
|
||||||
|
{/* Rating Display */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-5xl font-bold">
|
||||||
|
{synthesis.current_rating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
star <= Math.round(synthesis.current_rating)
|
||||||
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
|
: 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 text-sm mt-1">
|
||||||
|
{overview.total_reviews.toLocaleString()} reviews
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Potential */}
|
||||||
|
{synthesis.rating_gap > 0 && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
+{synthesis.rating_gap.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-slate-400 text-sm">
|
||||||
|
potential if fixed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline */}
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold mb-3 leading-tight">
|
||||||
|
{synthesis.headline}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Verdict */}
|
||||||
|
<p className="text-slate-300 text-lg mb-4">
|
||||||
|
{synthesis.verdict}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Momentum Indicator */}
|
||||||
|
{synthesis.momentum && synthesis.momentum_detail && (
|
||||||
|
<MomentumBadge momentum={synthesis.momentum} detail={synthesis.momentum_detail} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE STORY
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-4">
|
||||||
|
Executive Summary
|
||||||
|
</h2>
|
||||||
|
<div className="prose prose-slate max-w-none">
|
||||||
|
{synthesis.narrative.split('\n\n').map((paragraph, i) => (
|
||||||
|
<p key={i} className="text-slate-700 leading-relaxed mb-4 last:mb-0">
|
||||||
|
{paragraph}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE DIAGNOSIS
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{synthesis.primary_problem && (
|
||||||
|
<section className="bg-red-50 rounded-2xl p-8 border border-red-100">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="p-3 bg-red-100 rounded-xl">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-sm font-semibold text-red-800 uppercase tracking-wide mb-2">
|
||||||
|
Primary Issue
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl font-semibold text-red-900 mb-2">
|
||||||
|
{synthesis.primary_problem}
|
||||||
|
</p>
|
||||||
|
{synthesis.root_cause && (
|
||||||
|
<p className="text-red-700">
|
||||||
|
<span className="font-medium">Root cause:</span> {synthesis.root_cause}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE STRENGTHS (What to Protect)
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{synthesis.strengths && synthesis.strengths.length > 0 && (
|
||||||
|
<StrengthsSection strengths={synthesis.strengths} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE EVIDENCE
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{synthesis.evidence.length > 0 && (
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-6">
|
||||||
|
What Customers Are Saying
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{synthesis.evidence.map((item, i) => (
|
||||||
|
<EvidenceCard key={i} evidence={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE DATA (Visual Support)
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* Sentiment */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-1">
|
||||||
|
{synthesis.sentiment_headline || 'Sentiment Breakdown'}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4">
|
||||||
|
<SentimentBar positive={positivePct} negative={negativePct} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-1">
|
||||||
|
{synthesis.category_headline || 'Category Performance'}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{domains.slice(0, 4).map((d) => (
|
||||||
|
<DomainRow key={d.domain} domain={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
THE ACTION PLAN (Enhanced)
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
{synthesis.actions.length > 0 && (
|
||||||
|
<ActionPlanSection actions={synthesis.actions} ratingGap={synthesis.rating_gap} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ══════════════════════════════════════════════════════════════════
|
||||||
|
FOOTER
|
||||||
|
══════════════════════════════════════════════════════════════════ */}
|
||||||
|
<footer className="text-center text-sm text-slate-400 pt-4">
|
||||||
|
Report generated {new Date(synthesis.generated_at).toLocaleDateString()}
|
||||||
|
{' · '}{synthesis.review_count} reviews · {synthesis.insight_count} insights extracted
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Momentum Badge
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function MomentumBadge({ momentum, detail }: { momentum: string; detail: string }) {
|
||||||
|
const config = {
|
||||||
|
improving: {
|
||||||
|
icon: <TrendingUp className="w-4 h-4" />,
|
||||||
|
bg: 'bg-emerald-500/20',
|
||||||
|
text: 'text-emerald-300',
|
||||||
|
label: 'Improving',
|
||||||
|
},
|
||||||
|
declining: {
|
||||||
|
icon: <TrendingDown className="w-4 h-4" />,
|
||||||
|
bg: 'bg-red-500/20',
|
||||||
|
text: 'text-red-300',
|
||||||
|
label: 'Declining',
|
||||||
|
},
|
||||||
|
stable: {
|
||||||
|
icon: <Minus className="w-4 h-4" />,
|
||||||
|
bg: 'bg-slate-500/20',
|
||||||
|
text: 'text-slate-300',
|
||||||
|
label: 'Stable',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = config[momentum as keyof typeof config] || config.stable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full ${c.bg} ${c.text}`}>
|
||||||
|
{c.icon}
|
||||||
|
<span className="text-sm font-medium">{c.label}</span>
|
||||||
|
<span className="text-sm opacity-75">· {detail}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Strengths Section
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function StrengthsSection({ strengths }: { strengths: ReportStrength[] }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-emerald-50 rounded-2xl p-8 border border-emerald-100">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-emerald-100 rounded-xl">
|
||||||
|
<Trophy className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-emerald-800 uppercase tracking-wide">
|
||||||
|
Your Strengths
|
||||||
|
</h2>
|
||||||
|
<p className="text-emerald-600 text-sm">Protect and leverage these competitive advantages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{strengths.map((strength, i) => (
|
||||||
|
<StrengthCard key={i} strength={strength} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrengthCard({ strength }: { strength: ReportStrength }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl p-4 border border-emerald-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-slate-900">{strength.title}</h3>
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
|
||||||
|
{strength.mention_count} mentions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quote */}
|
||||||
|
{strength.quote && (
|
||||||
|
<div className="flex items-start gap-2 mb-3">
|
||||||
|
<Quote className="w-4 h-4 text-emerald-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-slate-600 italic">"{strength.quote}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Marketing Angle */}
|
||||||
|
{strength.marketing_angle && (
|
||||||
|
<div className="flex items-start gap-2 bg-amber-50 rounded-lg px-3 py-2">
|
||||||
|
<Megaphone className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<span className="font-medium">Marketing:</span> {strength.marketing_angle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Action Plan Section (Enhanced)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ActionPlanSection({ actions, ratingGap }: { actions: ReportAction[]; ratingGap: number }) {
|
||||||
|
// Group actions by effort/timeline
|
||||||
|
const quickWins = actions.filter(a => a.effort === 'quick_win');
|
||||||
|
const moderate = actions.filter(a => a.effort === 'moderate');
|
||||||
|
const strategic = actions.filter(a => a.effort === 'strategic');
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalImpact = actions.reduce((sum, a) => sum + (a.impact_stars || 0), 0);
|
||||||
|
const totalComplaints = actions.reduce((sum, a) => sum + (a.complaint_count || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
{/* Header with Impact Summary */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-1">
|
||||||
|
Action Plan
|
||||||
|
</h2>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{actions.length} actions to gain +{totalImpact.toFixed(1)}★
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
|
<span>Addresses {totalComplaints} complaints</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Impact Progress Bar */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-slate-400">Potential Impact</span>
|
||||||
|
<div className="flex-1 h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-500 to-emerald-400 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.min(100, (totalImpact / Math.max(ratingGap, 1)) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-emerald-400 font-medium">+{totalImpact.toFixed(1)}★</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Groups */}
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Quick Wins */}
|
||||||
|
{quickWins.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="Quick Wins"
|
||||||
|
subtitle="Implement this week"
|
||||||
|
icon={<Zap className="w-5 h-5" />}
|
||||||
|
iconBg="bg-amber-100 text-amber-600"
|
||||||
|
actions={quickWins}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* This Quarter */}
|
||||||
|
{moderate.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="This Quarter"
|
||||||
|
subtitle="Plan and execute over 1-3 months"
|
||||||
|
icon={<Clock className="w-5 h-5" />}
|
||||||
|
iconBg="bg-blue-100 text-blue-600"
|
||||||
|
actions={moderate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Strategic */}
|
||||||
|
{strategic.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="Strategic Initiatives"
|
||||||
|
subtitle="Long-term investments"
|
||||||
|
icon={<Target className="w-5 h-5" />}
|
||||||
|
iconBg="bg-purple-100 text-purple-600"
|
||||||
|
actions={strategic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionGroup({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconBg,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconBg: string;
|
||||||
|
actions: ReportAction[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Group Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={`p-2 rounded-lg ${iconBg}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||||
|
<p className="text-sm text-slate-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="space-y-3 pl-12">
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<EnhancedActionCard key={i} action={action} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnhancedActionCard({ action }: { action: ReportAction }) {
|
||||||
|
const priorityConfig = {
|
||||||
|
critical: { bg: 'bg-red-50 border-red-200', badge: 'bg-red-100 text-red-700', label: 'Critical' },
|
||||||
|
high: { bg: 'bg-orange-50 border-orange-200', badge: 'bg-orange-100 text-orange-700', label: 'High' },
|
||||||
|
medium: { bg: 'bg-slate-50 border-slate-200', badge: 'bg-slate-100 text-slate-700', label: 'Medium' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = priorityConfig[action.priority as keyof typeof priorityConfig] || priorityConfig.medium;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 rounded-xl border ${config.bg} transition-all hover:shadow-md`}>
|
||||||
|
{/* Action Title */}
|
||||||
|
<p className="font-semibold text-slate-900 mb-3">
|
||||||
|
{action.action}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Meta Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${config.badge}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner */}
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>{action.owner}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Impact */}
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<span className="text-emerald-600 font-medium">{action.impact}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Complaints */}
|
||||||
|
{action.complaint_count > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
<span>{action.complaint_count} complaints</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evidence Quote */}
|
||||||
|
{action.evidence && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-slate-500 italic border-l-2 border-slate-300 pl-3 mb-3">
|
||||||
|
<span>"{action.evidence}"</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Metric */}
|
||||||
|
{action.success_metric && (
|
||||||
|
<div className="flex items-center gap-2 text-sm bg-emerald-50 text-emerald-700 rounded-lg px-3 py-2">
|
||||||
|
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span><span className="font-medium">Success:</span> {action.success_metric}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Sub-components
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function EvidenceCard({ evidence }: { evidence: ReportEvidence }) {
|
||||||
|
const isDamaging = evidence.sentiment === 'damaging';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`p-4 rounded-xl border-l-4 ${
|
||||||
|
isDamaging
|
||||||
|
? 'bg-red-50 border-red-400'
|
||||||
|
: 'bg-emerald-50 border-emerald-400'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Quote className={`w-5 h-5 mt-0.5 flex-shrink-0 ${
|
||||||
|
isDamaging ? 'text-red-400' : 'text-emerald-400'
|
||||||
|
}`} />
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${isDamaging ? 'text-red-900' : 'text-emerald-900'}`}>
|
||||||
|
"{evidence.quote}"
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${isDamaging ? 'text-red-600' : 'text-emerald-600'}`}>
|
||||||
|
{evidence.context}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SentimentBar({ positive, negative }: { positive: number; negative: number }) {
|
||||||
|
const neutral = Math.max(0, 100 - positive - negative);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="h-4 rounded-full overflow-hidden flex bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 transition-all"
|
||||||
|
style={{ width: `${positive}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-slate-300 transition-all"
|
||||||
|
style={{ width: `${neutral}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-red-500 transition-all"
|
||||||
|
style={{ width: `${negative}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-sm">
|
||||||
|
<span className="text-emerald-600 font-medium">{positive.toFixed(0)}% positive</span>
|
||||||
|
<span className="text-red-600 font-medium">{negative.toFixed(0)}% negative</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DomainRow({ domain }: { domain: URTDomainPoint }) {
|
||||||
|
const config = DOMAINS[domain.domain] || { emoji: '📊', label: domain.domain_name };
|
||||||
|
const total = domain.positive_count + domain.negative_count;
|
||||||
|
const negativePct = total > 0 ? (domain.negative_count / total) * 100 : 0;
|
||||||
|
|
||||||
|
const status = negativePct > 40 ? 'critical' : negativePct > 25 ? 'warning' : 'good';
|
||||||
|
const statusColors = {
|
||||||
|
critical: 'text-red-600',
|
||||||
|
warning: 'text-orange-600',
|
||||||
|
good: 'text-emerald-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{config.emoji}</span>
|
||||||
|
<span className="flex-1 text-sm text-slate-700">{config.label}</span>
|
||||||
|
<span className={`text-sm font-medium ${statusColors[status]}`}>
|
||||||
|
{negativePct.toFixed(0)}% issues
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
649
web/components/reviewiq/BusinessReport.tsx
Normal file
649
web/components/reviewiq/BusinessReport.tsx
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Minus,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Quote,
|
||||||
|
Zap,
|
||||||
|
Clock,
|
||||||
|
Target,
|
||||||
|
Users,
|
||||||
|
Trophy,
|
||||||
|
Megaphone,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Shield,
|
||||||
|
Activity,
|
||||||
|
BarChart3,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
SynthesisV2,
|
||||||
|
ExecutiveSummary,
|
||||||
|
RiskScorecard,
|
||||||
|
RiskIndicator,
|
||||||
|
CriticalIssue,
|
||||||
|
StrengthToProtect,
|
||||||
|
ActionMatrixItem,
|
||||||
|
TrackingKPI,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
interface BusinessReportProps {
|
||||||
|
synthesis: SynthesisV2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The €60 Business Reputation Report - 6-Section Format
|
||||||
|
* A productized, high-value report for SMB owners.
|
||||||
|
*/
|
||||||
|
export function BusinessReport({ synthesis }: BusinessReportProps) {
|
||||||
|
const { executive_summary, risk_scorecard, critical_issues, strengths, action_matrix, tracking_kpis } = synthesis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 pb-12">
|
||||||
|
{/* Report Header */}
|
||||||
|
<header className="text-center py-4">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">{synthesis.report_title}</h1>
|
||||||
|
<p className="text-slate-500">{synthesis.report_date} · {synthesis.analysis_period}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Section 1: Executive Summary */}
|
||||||
|
<ExecutiveSummarySection summary={executive_summary} reviewCount={synthesis.review_count} />
|
||||||
|
|
||||||
|
{/* Section 2: Risk Scorecard */}
|
||||||
|
<RiskScorecardSection scorecard={risk_scorecard} />
|
||||||
|
|
||||||
|
{/* Section 3: Critical Issues */}
|
||||||
|
{critical_issues.length > 0 && (
|
||||||
|
<CriticalIssuesSection issues={critical_issues} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 4: Strengths to Protect */}
|
||||||
|
{strengths.length > 0 && (
|
||||||
|
<StrengthsSection strengths={strengths} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 5: Action Matrix */}
|
||||||
|
{action_matrix.length > 0 && (
|
||||||
|
<ActionMatrixSection actions={action_matrix} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 6: 90-Day Tracking */}
|
||||||
|
{tracking_kpis.length > 0 && (
|
||||||
|
<TrackingSection kpis={tracking_kpis} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="text-center text-sm text-slate-400 pt-4 border-t border-slate-200">
|
||||||
|
<p>Report generated {new Date(synthesis.generated_at).toLocaleDateString()}</p>
|
||||||
|
<p>{synthesis.review_count} reviews · {synthesis.insight_count} insights analyzed</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 1: Executive Summary
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ExecutiveSummarySection({ summary, reviewCount }: { summary: ExecutiveSummary; reviewCount: number }) {
|
||||||
|
const healthColor = summary.health_score >= 70 ? 'emerald' : summary.health_score >= 50 ? 'amber' : 'red';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-gradient-to-br from-slate-900 to-slate-800 rounded-2xl p-8 text-white">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
{/* Health Score Gauge */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative">
|
||||||
|
<svg className="w-24 h-24 transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx="48"
|
||||||
|
cy="48"
|
||||||
|
r="40"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
className="text-slate-700"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="48"
|
||||||
|
cy="48"
|
||||||
|
r="40"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${(summary.health_score / 100) * 251.2} 251.2`}
|
||||||
|
className={`text-${healthColor}-400`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold">{summary.health_score}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={`inline-block px-3 py-1 rounded-full text-sm font-medium bg-${healthColor}-500/20 text-${healthColor}-300 mb-2`}>
|
||||||
|
{summary.health_label}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-sm">{reviewCount.toLocaleString()} reviews analyzed</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating Display */}
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2 mb-1">
|
||||||
|
<span className="text-4xl font-bold">{summary.current_rating.toFixed(1)}</span>
|
||||||
|
<div className="flex">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
star <= Math.round(summary.current_rating)
|
||||||
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
|
: 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{summary.rating_gap > 0 && (
|
||||||
|
<div className="flex items-center justify-end gap-2 text-emerald-400">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span className="font-medium">→ {summary.potential_rating.toFixed(1)}★ potential</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* One-liner */}
|
||||||
|
<h2 className="text-xl font-semibold mb-3">{summary.one_liner}</h2>
|
||||||
|
|
||||||
|
{/* Key Insight */}
|
||||||
|
<p className="text-slate-300 mb-4">{summary.key_insight}</p>
|
||||||
|
|
||||||
|
{/* Revenue at Risk + Momentum */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-slate-700">
|
||||||
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{summary.estimated_revenue_at_risk} at risk</span>
|
||||||
|
</div>
|
||||||
|
<MomentumBadge momentum={summary.momentum} detail={summary.momentum_detail} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 2: Risk Scorecard
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function RiskScorecardSection({ scorecard }: { scorecard: RiskScorecard }) {
|
||||||
|
const riskColors = {
|
||||||
|
low: 'emerald',
|
||||||
|
medium: 'amber',
|
||||||
|
high: 'orange',
|
||||||
|
critical: 'red',
|
||||||
|
};
|
||||||
|
const riskColor = riskColors[scorecard.overall_risk] || 'amber';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-slate-100 rounded-xl">
|
||||||
|
<Shield className="w-6 h-6 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Risk Scorecard</h2>
|
||||||
|
<p className="text-sm text-slate-500">Health indicators by area</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`px-4 py-2 rounded-full bg-${riskColor}-100 text-${riskColor}-700 font-semibold text-sm uppercase`}>
|
||||||
|
{scorecard.overall_risk} Risk
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk Indicators Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
{scorecard.indicators.map((indicator, i) => (
|
||||||
|
<RiskIndicatorCard key={i} indicator={indicator} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Immediate Attention */}
|
||||||
|
{scorecard.immediate_attention && (
|
||||||
|
<div className="flex items-start gap-3 bg-red-50 rounded-xl p-4 border border-red-100">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-900">Immediate Attention Required</p>
|
||||||
|
<p className="text-sm text-red-700">{scorecard.immediate_attention}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskIndicatorCard({ indicator }: { indicator: RiskIndicator }) {
|
||||||
|
const colorMap = {
|
||||||
|
green: { bg: 'bg-emerald-50', border: 'border-emerald-200', text: 'text-emerald-700', bar: 'bg-emerald-500' },
|
||||||
|
yellow: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-700', bar: 'bg-amber-500' },
|
||||||
|
red: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', bar: 'bg-red-500' },
|
||||||
|
};
|
||||||
|
const colors = colorMap[indicator.color] || colorMap.yellow;
|
||||||
|
|
||||||
|
const TrendIcon = indicator.trend === 'improving' ? TrendingUp : indicator.trend === 'declining' ? TrendingDown : Minus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${colors.bg} ${colors.border} border rounded-xl p-4`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700">{indicator.name}</span>
|
||||||
|
<TrendIcon className={`w-4 h-4 ${colors.text}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<span className={`text-2xl font-bold ${colors.text}`}>{indicator.score}</span>
|
||||||
|
<span className="text-slate-400 text-sm mb-1">/10</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${colors.bar} rounded-full`} style={{ width: `${indicator.score * 10}%` }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">{indicator.complaint_count} complaints</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 3: Critical Issues
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function CriticalIssuesSection({ issues }: { issues: CriticalIssue[] }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-red-100 rounded-xl">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Critical Issues</h2>
|
||||||
|
<p className="text-sm text-slate-500">Top problems requiring immediate action</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<CriticalIssueCard key={issue.rank} issue={issue} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CriticalIssueCard({ issue }: { issue: CriticalIssue }) {
|
||||||
|
const effortColors = {
|
||||||
|
quick_win: 'bg-emerald-100 text-emerald-700',
|
||||||
|
moderate: 'bg-blue-100 text-blue-700',
|
||||||
|
strategic: 'bg-purple-100 text-purple-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-50 px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center font-bold">
|
||||||
|
{issue.rank}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{issue.title}</h3>
|
||||||
|
<p className="text-sm text-slate-500">{issue.urt_code} · {issue.complaint_count} complaints</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-red-600 font-semibold">{issue.revenue_impact}</p>
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${effortColors[issue.effort] || effortColors.moderate}`}>
|
||||||
|
{issue.effort.replace('_', ' ')} · {issue.timeline}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
{/* Root Cause */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500 mb-1">Root Cause</p>
|
||||||
|
<p className="text-slate-700">{issue.root_cause}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evidence */}
|
||||||
|
{issue.evidence.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-500 mb-2">Customer Evidence</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{issue.evidence.slice(0, 2).map((quote, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 bg-red-50 rounded-lg px-3 py-2">
|
||||||
|
<Quote className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-red-800 italic">"{quote}"</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Solution */}
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-4 border border-emerald-100">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-emerald-500 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-emerald-900">Recommended Solution</p>
|
||||||
|
<p className="text-sm text-emerald-700 mt-1">{issue.solution}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 4: Strengths to Protect
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function StrengthsSection({ strengths }: { strengths: StrengthToProtect[] }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-emerald-50 rounded-2xl p-8 border border-emerald-100">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-emerald-100 rounded-xl">
|
||||||
|
<Trophy className="w-6 h-6 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-emerald-900">Protect Your Strengths</h2>
|
||||||
|
<p className="text-sm text-emerald-600">Competitive advantages to leverage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{strengths.map((strength, i) => (
|
||||||
|
<StrengthCard key={i} strength={strength} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StrengthCard({ strength }: { strength: StrengthToProtect }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-emerald-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-slate-900">{strength.title}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
|
||||||
|
{strength.mention_count} mentions
|
||||||
|
</span>
|
||||||
|
<span className="text-emerald-600 font-medium">{strength.percentage.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quotes */}
|
||||||
|
{strength.top_quotes.length > 0 && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{strength.top_quotes.slice(0, 2).map((quote, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<Quote className="w-4 h-4 text-emerald-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-slate-600 italic">"{quote.slice(0, 100)}{quote.length > 100 ? '...' : ''}"</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Risk of Loss */}
|
||||||
|
{strength.risk_of_loss && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-amber-700 bg-amber-50 rounded-lg px-3 py-2 mb-3">
|
||||||
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>{strength.risk_of_loss}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Leverage Action */}
|
||||||
|
{strength.leverage_action && (
|
||||||
|
<div className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
|
||||||
|
<Megaphone className="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<span className="font-medium">Leverage:</span> {strength.leverage_action}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 5: Action Matrix
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function ActionMatrixSection({ actions }: { actions: ActionMatrixItem[] }) {
|
||||||
|
const quickWins = actions.filter(a => a.quadrant === 'quick_win');
|
||||||
|
const majorProjects = actions.filter(a => a.quadrant === 'major_project');
|
||||||
|
const others = actions.filter(a => !['quick_win', 'major_project'].includes(a.quadrant));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-6 text-white">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-white/10 rounded-lg">
|
||||||
|
<BarChart3 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Action Matrix</h2>
|
||||||
|
<p className="text-slate-400 text-sm">{actions.length} prioritized actions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Quick Wins */}
|
||||||
|
{quickWins.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="Quick Wins"
|
||||||
|
subtitle="Low effort, high impact - do these first"
|
||||||
|
icon={<Zap className="w-5 h-5" />}
|
||||||
|
iconBg="bg-amber-100 text-amber-600"
|
||||||
|
actions={quickWins}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Major Projects */}
|
||||||
|
{majorProjects.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="Major Projects"
|
||||||
|
subtitle="High effort, high impact - plan carefully"
|
||||||
|
icon={<Target className="w-5 h-5" />}
|
||||||
|
iconBg="bg-blue-100 text-blue-600"
|
||||||
|
actions={majorProjects}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Others */}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<ActionGroup
|
||||||
|
title="Other Actions"
|
||||||
|
subtitle="Consider as resources allow"
|
||||||
|
icon={<Clock className="w-5 h-5" />}
|
||||||
|
iconBg="bg-slate-100 text-slate-600"
|
||||||
|
actions={others}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionGroup({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconBg,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconBg: string;
|
||||||
|
actions: ActionMatrixItem[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={`p-2 rounded-lg ${iconBg}`}>{icon}</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||||
|
<p className="text-sm text-slate-500">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 pl-12">
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<ActionCard key={i} action={action} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionCard({ action }: { action: ActionMatrixItem }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 rounded-xl p-4 border border-slate-200 hover:shadow-md transition-all">
|
||||||
|
<p className="font-medium text-slate-900 mb-3">{action.action}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>{action.owner}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>{action.deadline}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
<span className="text-emerald-600 font-medium">{action.expected_lift}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-600">
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
<span>{action.effort} effort</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action.success_metric && (
|
||||||
|
<div className="flex items-center gap-2 text-sm bg-emerald-50 text-emerald-700 rounded-lg px-3 py-2">
|
||||||
|
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span><span className="font-medium">Success:</span> {action.success_metric}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Section 6: 90-Day Tracking
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function TrackingSection({ kpis }: { kpis: TrackingKPI[] }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white rounded-2xl p-8 shadow-sm border border-slate-200">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-purple-100 rounded-xl">
|
||||||
|
<Calendar className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">90-Day Tracking Framework</h2>
|
||||||
|
<p className="text-sm text-slate-500">Monitor these KPIs monthly</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200">
|
||||||
|
<th className="text-left py-3 px-4 text-sm font-semibold text-slate-600">Metric</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">Current</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">30-Day</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">60-Day</th>
|
||||||
|
<th className="text-center py-3 px-4 text-sm font-semibold text-slate-600">90-Day</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kpis.map((kpi, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-100 last:border-0">
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
<p className="font-medium text-slate-900">{kpi.metric}</p>
|
||||||
|
<p className="text-xs text-slate-500">{kpi.measurement}</p>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-4 px-4">
|
||||||
|
<span className="px-3 py-1 bg-red-100 text-red-700 rounded-full text-sm font-medium">
|
||||||
|
{kpi.current_value}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-4 px-4">
|
||||||
|
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-full text-sm font-medium">
|
||||||
|
{kpi.target_30_day}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-4 px-4">
|
||||||
|
<span className="px-3 py-1 bg-blue-100 text-blue-700 rounded-full text-sm font-medium">
|
||||||
|
{kpi.target_60_day}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-4 px-4">
|
||||||
|
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">
|
||||||
|
{kpi.target_90_day}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Shared Components
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function MomentumBadge({ momentum, detail }: { momentum: string; detail: string }) {
|
||||||
|
const config = {
|
||||||
|
improving: {
|
||||||
|
icon: <TrendingUp className="w-4 h-4" />,
|
||||||
|
bg: 'bg-emerald-500/20',
|
||||||
|
text: 'text-emerald-300',
|
||||||
|
label: 'Improving',
|
||||||
|
},
|
||||||
|
declining: {
|
||||||
|
icon: <TrendingDown className="w-4 h-4" />,
|
||||||
|
bg: 'bg-red-500/20',
|
||||||
|
text: 'text-red-300',
|
||||||
|
label: 'Declining',
|
||||||
|
},
|
||||||
|
stable: {
|
||||||
|
icon: <Minus className="w-4 h-4" />,
|
||||||
|
bg: 'bg-slate-500/20',
|
||||||
|
text: 'text-slate-300',
|
||||||
|
label: 'Stable',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const c = config[momentum as keyof typeof config] || config.stable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-full ${c.bg} ${c.text}`}>
|
||||||
|
{c.icon}
|
||||||
|
<span className="text-sm font-medium">{c.label}</span>
|
||||||
|
{detail && <span className="text-sm opacity-75">· {detail}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
web/components/reviewiq/ReportTab.tsx
Normal file
101
web/components/reviewiq/ReportTab.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Loader2, FileWarning } from 'lucide-react';
|
||||||
|
import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
|
||||||
|
import { AnalystReport } from './AnalystReport';
|
||||||
|
import { BusinessReport } from './BusinessReport';
|
||||||
|
import { isSynthesisV2 } from './types';
|
||||||
|
import type { ReviewIQFilters, LegacySynthesis, SynthesisV2 } from './types';
|
||||||
|
|
||||||
|
interface ReportTabProps {
|
||||||
|
jobId?: string;
|
||||||
|
businessId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default filters for Report view - uses 'all' time range for comprehensive analysis
|
||||||
|
const defaultFilters: ReviewIQFilters = {
|
||||||
|
timeRange: 'all',
|
||||||
|
sentiment: [],
|
||||||
|
urtDomain: null,
|
||||||
|
intensity: [],
|
||||||
|
brushRange: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report Tab - Wraps report components with data fetching.
|
||||||
|
* Automatically detects report version and renders appropriate component.
|
||||||
|
*/
|
||||||
|
export function ReportTab({ jobId, businessId }: ReportTabProps) {
|
||||||
|
const { data, loading, error } = useReviewIQAnalytics({
|
||||||
|
jobId,
|
||||||
|
businessId,
|
||||||
|
filters: defaultFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data state
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-slate-600">No data available for this report.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No synthesis - AI report not generated yet
|
||||||
|
if (!data.synthesis) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto mt-12">
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-8 text-center">
|
||||||
|
<FileWarning className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-amber-900 mb-2">
|
||||||
|
AI Report Not Generated Yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-700 mb-4">
|
||||||
|
The AI-powered analyst report hasn't been generated for this dataset.
|
||||||
|
Run the pipeline with the "synthesize" stage to generate the report.
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-amber-600 bg-amber-100 rounded-lg p-3 font-mono">
|
||||||
|
Stage 5: Synthesize → Generates narratives, actions & insights
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the appropriate report based on synthesis version
|
||||||
|
if (isSynthesisV2(data.synthesis)) {
|
||||||
|
// New 6-section Business Report (v2.0)
|
||||||
|
return <BusinessReport synthesis={data.synthesis} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy Analyst Report (v1.x)
|
||||||
|
return (
|
||||||
|
<AnalystReport
|
||||||
|
synthesis={data.synthesis}
|
||||||
|
overview={data.overview}
|
||||||
|
sentiment={data.sentiment.distribution}
|
||||||
|
domains={data.urt.domains}
|
||||||
|
timeline={data.timeline}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportTab;
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { RefreshCw, BarChart3 } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { ReviewIQFilterProvider, useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
import { ReviewIQFilterProvider, useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||||
import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
|
import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
|
||||||
import { FilterBar } from './FilterBar';
|
|
||||||
import { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton';
|
import { DashboardSkeleton, DashboardError, DashboardEmpty } from './DashboardSkeleton';
|
||||||
|
import { FilterBar } from './FilterBar';
|
||||||
import { SentimentPie } from './charts/SentimentPie';
|
import { SentimentPie } from './charts/SentimentPie';
|
||||||
import { IntensityHeatmap } from './charts/IntensityHeatmap';
|
import { IntensityHeatmap } from './charts/IntensityHeatmap';
|
||||||
import { TimelineChart } from './charts/TimelineChart';
|
import { TimelineChart } from './charts/TimelineChart';
|
||||||
import { IssuesTable } from './tables/IssuesTable';
|
import { IssuesTable } from './tables/IssuesTable';
|
||||||
import { SpansTable } from './tables/SpansTable';
|
import { SpansTable } from './tables/SpansTable';
|
||||||
import { ExecutiveSummary } from './insights/ExecutiveSummary';
|
|
||||||
import { OpportunityMatrix } from './insights/OpportunityMatrix';
|
import { OpportunityMatrix } from './insights/OpportunityMatrix';
|
||||||
import type { URTDomain } from './types';
|
import { ExplorerView } from './ExplorerView';
|
||||||
|
import type { URTDomain, Synthesis, LegacySynthesis } from './types';
|
||||||
|
import { isSynthesisV2 } from './types';
|
||||||
|
|
||||||
|
// Helper to extract legacy fields from either synthesis format
|
||||||
|
function getLegacyInsight(synthesis: Synthesis | null | undefined, field: keyof LegacySynthesis): string | undefined {
|
||||||
|
if (!synthesis) return undefined;
|
||||||
|
if (isSynthesisV2(synthesis)) {
|
||||||
|
// V2 doesn't have these fields, return undefined
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (synthesis as LegacySynthesis)[field] as string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface ReviewIQDashboardProps {
|
interface ReviewIQDashboardProps {
|
||||||
jobId?: string | null;
|
jobId?: string | null;
|
||||||
@@ -22,13 +33,7 @@ interface ReviewIQDashboardProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner dashboard component that uses the filter context.
|
* Inner dashboard component that uses the filter context.
|
||||||
*
|
* Shows data exploration view with charts, tables, and trend explorer.
|
||||||
* 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) {
|
function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
||||||
const { filters, setURTDomain } = useReviewIQFilters();
|
const { filters, setURTDomain } = useReviewIQFilters();
|
||||||
@@ -45,9 +50,6 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
|||||||
spansPageSize: 10,
|
spansPageSize: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleIssuesPageChange = (page: number) => setIssuesPage(page);
|
|
||||||
const handleSpansPageChange = (page: number) => setSpansPage(page);
|
|
||||||
|
|
||||||
// No job selected
|
// No job selected
|
||||||
if (!jobId && !businessId) {
|
if (!jobId && !businessId) {
|
||||||
return <DashboardEmpty />;
|
return <DashboardEmpty />;
|
||||||
@@ -68,106 +70,110 @@ function ReviewIQDashboardInner({ jobId, businessId }: ReviewIQDashboardProps) {
|
|||||||
return <DashboardEmpty />;
|
return <DashboardEmpty />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle domain click for filtering
|
|
||||||
const handleDomainClick = (domain: URTDomain) => {
|
|
||||||
setURTDomain(filters.urtDomain === domain ? null : domain);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* Header */}
|
||||||
HEADER
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl">
|
<h1 className="text-xl font-semibold text-gray-900">Data Explorer</h1>
|
||||||
<BarChart3 className="w-6 h-6 text-white" />
|
<p className="text-sm text-gray-500">
|
||||||
</div>
|
{data.overview.total_reviews.toLocaleString()} reviews · {data.overview.total_spans.toLocaleString()} insights
|
||||||
<div>
|
</p>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">ReviewIQ Analytics</h1>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{data.overview.total_reviews.toLocaleString()} reviews • {data.overview.total_spans.toLocaleString()} insights extracted
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={refetch}
|
onClick={refetch}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
|
className="px-4 py-2 bg-slate-900 text-white rounded-lg font-medium hover:bg-slate-800 transition-colors flex items-center gap-2 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Filters Bar */}
|
{/* Data View */}
|
||||||
|
<DataView
|
||||||
|
data={data}
|
||||||
|
filters={filters}
|
||||||
|
setURTDomain={setURTDomain}
|
||||||
|
issuesPage={issuesPage}
|
||||||
|
spansPage={spansPage}
|
||||||
|
setIssuesPage={setIssuesPage}
|
||||||
|
setSpansPage={setSpansPage}
|
||||||
|
jobId={jobId || undefined}
|
||||||
|
businessId={businessId || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data view - the detailed charts and tables
|
||||||
|
*/
|
||||||
|
function DataView({
|
||||||
|
data,
|
||||||
|
filters,
|
||||||
|
setURTDomain,
|
||||||
|
issuesPage,
|
||||||
|
spansPage,
|
||||||
|
setIssuesPage,
|
||||||
|
setSpansPage,
|
||||||
|
jobId,
|
||||||
|
businessId,
|
||||||
|
}: {
|
||||||
|
data: NonNullable<ReturnType<typeof useReviewIQAnalytics>['data']>;
|
||||||
|
filters: ReturnType<typeof useReviewIQFilters>['filters'];
|
||||||
|
setURTDomain: ReturnType<typeof useReviewIQFilters>['setURTDomain'];
|
||||||
|
issuesPage: number;
|
||||||
|
spansPage: number;
|
||||||
|
setIssuesPage: (page: number) => void;
|
||||||
|
setSpansPage: (page: number) => void;
|
||||||
|
jobId?: string;
|
||||||
|
businessId?: string;
|
||||||
|
}) {
|
||||||
|
const handleDomainClick = (domain: URTDomain) => {
|
||||||
|
setURTDomain(filters.urtDomain === domain ? null : domain);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Filters */}
|
||||||
<FilterBar />
|
<FilterBar />
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* Sentiment + Categories */}
|
||||||
SECTION 1: EXECUTIVE SUMMARY (Hero)
|
|
||||||
Rating, AI summary, #1 Problem, #1 Strength, Top Complaints
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<ExecutiveSummary
|
|
||||||
insights={data.insights}
|
|
||||||
avgRating={data.overview.avg_rating}
|
|
||||||
domainScores={data.domain_scores}
|
|
||||||
onDomainClick={handleDomainClick}
|
|
||||||
synthesis={data.synthesis}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
|
||||||
SECTION 2: EXPLORE (Sentiment + Categories)
|
|
||||||
Side-by-side: How customers feel + What they talk about
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<SentimentPie
|
<SentimentPie
|
||||||
data={data.sentiment.distribution}
|
data={data.sentiment.distribution}
|
||||||
insight={data.synthesis?.sentiment_insight}
|
insight={getLegacyInsight(data.synthesis, 'sentiment_headline')}
|
||||||
/>
|
/>
|
||||||
<IntensityHeatmap
|
<IntensityHeatmap
|
||||||
data={data.urt.domains}
|
data={data.urt.domains}
|
||||||
insight={data.synthesis?.category_insight}
|
insight={getLegacyInsight(data.synthesis, 'category_headline')}
|
||||||
highlightDomain={data.synthesis?.priority_domain}
|
highlightDomain={getLegacyInsight(data.synthesis, 'primary_problem_code')?.charAt(0)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* Opportunity Matrix */}
|
||||||
SECTION 3: ACTION (Opportunity Matrix)
|
|
||||||
What to fix - prioritized by impact vs effort
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<OpportunityMatrix matrix={data.insights.opportunity_matrix} />
|
<OpportunityMatrix matrix={data.insights.opportunity_matrix} />
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* Timeline */}
|
||||||
SECTION 4: TRENDS (Timeline)
|
|
||||||
How things change over time
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<TimelineChart
|
<TimelineChart
|
||||||
data={data.timeline}
|
data={data.timeline}
|
||||||
insight={data.synthesis?.timeline_insight}
|
insight={getLegacyInsight(data.synthesis, 'timeline_headline')}
|
||||||
annotations={data.synthesis?.timeline_annotations}
|
granularity={(data.filters_applied?.granularity as 'day' | 'week' | 'month' | 'year') || 'week'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════
|
{/* Tables */}
|
||||||
SECTION 5: DEEP DIVE (Tables)
|
|
||||||
Detailed issues and individual mentions
|
|
||||||
═══════════════════════════════════════════════════════════════ */}
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
<IssuesTable issues={data.issues} onPageChange={handleIssuesPageChange} />
|
<IssuesTable issues={data.issues} onPageChange={setIssuesPage} />
|
||||||
<SpansTable spans={data.spans} onPageChange={handleSpansPageChange} />
|
<SpansTable spans={data.spans} onPageChange={setSpansPage} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Debug Info (dev only) */}
|
{/* Trend Explorer */}
|
||||||
{process.env.NODE_ENV === 'development' && (
|
<div className="mt-8">
|
||||||
<details className="bg-gray-100 rounded-lg p-4 text-sm">
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Trend Explorer</h2>
|
||||||
<summary className="cursor-pointer font-semibold text-gray-700">
|
<ExplorerView jobId={jobId} businessId={businessId} />
|
||||||
Debug: Filters Applied
|
</div>
|
||||||
</summary>
|
</>
|
||||||
<pre className="mt-2 bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
|
|
||||||
{JSON.stringify(data.filters_applied, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
830
web/components/reviewiq/StoryView.tsx
Normal file
830
web/components/reviewiq/StoryView.tsx
Normal file
@@ -0,0 +1,830 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
ReferenceLine,
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
ReviewIQAnalyticsResponse,
|
||||||
|
TimelinePoint,
|
||||||
|
StrengthItem,
|
||||||
|
WeaknessItem,
|
||||||
|
ReportAction,
|
||||||
|
DOMAIN_FRIENDLY,
|
||||||
|
Synthesis,
|
||||||
|
LegacySynthesis,
|
||||||
|
isSynthesisV2,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Helper to safely get legacy synthesis fields
|
||||||
|
function getLegacyField<K extends keyof LegacySynthesis>(
|
||||||
|
synthesis: Synthesis | null | undefined,
|
||||||
|
field: K
|
||||||
|
): LegacySynthesis[K] | undefined {
|
||||||
|
if (!synthesis) return undefined;
|
||||||
|
if (isSynthesisV2(synthesis)) return undefined;
|
||||||
|
return (synthesis as LegacySynthesis)[field];
|
||||||
|
}
|
||||||
|
import { useReviewIQAnalytics } from '@/hooks/useReviewIQAnalytics';
|
||||||
|
import type { ReviewIQFilters } from './types';
|
||||||
|
|
||||||
|
// Default filters for Story view - uses 'all' time range for comprehensive narrative
|
||||||
|
const defaultFilters: ReviewIQFilters = {
|
||||||
|
timeRange: 'all',
|
||||||
|
sentiment: [],
|
||||||
|
urtDomain: null,
|
||||||
|
intensity: [],
|
||||||
|
brushRange: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Props ====================
|
||||||
|
|
||||||
|
interface StoryViewProps {
|
||||||
|
jobId?: string;
|
||||||
|
businessId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helper Functions ====================
|
||||||
|
|
||||||
|
interface StoryPoint {
|
||||||
|
date: string;
|
||||||
|
rating: number | null;
|
||||||
|
type: 'peak' | 'valley' | 'normal';
|
||||||
|
change: number;
|
||||||
|
reviewCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identifyStoryPoints(timeline: TimelinePoint[]): StoryPoint[] {
|
||||||
|
if (timeline.length < 3) {
|
||||||
|
return timeline.map((t) => ({
|
||||||
|
date: t.date,
|
||||||
|
rating: t.avg_rating,
|
||||||
|
type: 'normal' as const,
|
||||||
|
change: 0,
|
||||||
|
reviewCount: t.review_count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const points: StoryPoint[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < timeline.length; i++) {
|
||||||
|
const current = timeline[i];
|
||||||
|
const prev = timeline[i - 1];
|
||||||
|
const next = timeline[i + 1];
|
||||||
|
|
||||||
|
let type: 'peak' | 'valley' | 'normal' = 'normal';
|
||||||
|
let change = 0;
|
||||||
|
|
||||||
|
if (current.avg_rating !== null) {
|
||||||
|
if (prev && prev.avg_rating !== null) {
|
||||||
|
change = current.avg_rating - prev.avg_rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify peaks and valleys
|
||||||
|
if (prev && next && prev.avg_rating !== null && next.avg_rating !== null) {
|
||||||
|
if (current.avg_rating > prev.avg_rating && current.avg_rating > next.avg_rating) {
|
||||||
|
type = 'peak';
|
||||||
|
} else if (current.avg_rating < prev.avg_rating && current.avg_rating < next.avg_rating) {
|
||||||
|
type = 'valley';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also mark significant changes (> 0.3 stars)
|
||||||
|
if (Math.abs(change) > 0.3) {
|
||||||
|
type = change > 0 ? 'peak' : 'valley';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
date: current.date,
|
||||||
|
rating: current.avg_rating,
|
||||||
|
type,
|
||||||
|
change,
|
||||||
|
reviewCount: current.review_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmotionalHook(currentRating: number, potentialRating: number): string {
|
||||||
|
const gap = potentialRating - currentRating;
|
||||||
|
|
||||||
|
if (gap >= 0.5) {
|
||||||
|
return "You're leaving stars on the table. Let's get them back.";
|
||||||
|
} else if (gap >= 0.3) {
|
||||||
|
return "Small changes can unlock big improvements.";
|
||||||
|
} else if (gap >= 0.1) {
|
||||||
|
return "You're close to excellence. Let's close the gap.";
|
||||||
|
} else {
|
||||||
|
return "Maintain your momentum and protect what you've built.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(priority: string): string {
|
||||||
|
switch (priority) {
|
||||||
|
case 'critical':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'high':
|
||||||
|
return 'bg-orange-500';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffortLabel(effort: string): string {
|
||||||
|
switch (effort) {
|
||||||
|
case 'quick_win':
|
||||||
|
return 'Quick Win';
|
||||||
|
case 'moderate':
|
||||||
|
return 'Moderate Effort';
|
||||||
|
case 'strategic':
|
||||||
|
return 'Strategic';
|
||||||
|
default:
|
||||||
|
return effort;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Section Components ====================
|
||||||
|
|
||||||
|
interface HookSectionProps {
|
||||||
|
headline: string;
|
||||||
|
currentRating: number;
|
||||||
|
potentialRating: number;
|
||||||
|
emotionalHook: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookSection({ headline, currentRating, potentialRating, emotionalHook }: HookSectionProps) {
|
||||||
|
const gap = potentialRating - currentRating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8 md:p-12">
|
||||||
|
{/* Background decorative elements */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-24 -right-24 h-96 w-96 rounded-full bg-blue-500/10 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-24 -left-24 h-96 w-96 rounded-full bg-purple-500/10 blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-6 leading-tight">
|
||||||
|
{headline}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-8 mb-8">
|
||||||
|
{/* Current Rating */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-bold text-white mb-1">
|
||||||
|
{currentRating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-sm uppercase tracking-wider">
|
||||||
|
Current Rating
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-0.5 w-12 bg-gradient-to-r from-slate-600 to-emerald-500" />
|
||||||
|
<svg className="w-6 h-6 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Potential Rating */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-bold text-emerald-400 mb-1">
|
||||||
|
{potentialRating.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-sm uppercase tracking-wider">
|
||||||
|
Potential Rating
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gap Badge */}
|
||||||
|
{gap > 0 && (
|
||||||
|
<div className="ml-auto">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-emerald-500/20 border border-emerald-500/30">
|
||||||
|
<svg className="w-5 h-5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-emerald-400 font-semibold">+{gap.toFixed(1)} stars possible</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xl text-slate-300 italic">
|
||||||
|
“{emotionalHook}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineSectionProps {
|
||||||
|
storyPoints: StoryPoint[];
|
||||||
|
timelineHeadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineSection({ storyPoints, timelineHeadline }: TimelineSectionProps) {
|
||||||
|
const significantPoints = storyPoints.filter((p) => p.type !== 'normal' || p.reviewCount > 0);
|
||||||
|
const displayPoints = significantPoints.length > 0 ? significantPoints : storyPoints.slice(0, 6);
|
||||||
|
|
||||||
|
// Prepare chart data - take last 12 points
|
||||||
|
const chartData = storyPoints.slice(-12).map((point) => ({
|
||||||
|
date: formatDate(point.date),
|
||||||
|
rating: point.rating ?? 0,
|
||||||
|
reviews: point.reviewCount,
|
||||||
|
type: point.type,
|
||||||
|
change: point.change,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate average rating for reference line
|
||||||
|
const avgRating = chartData.reduce((sum, p) => sum + p.rating, 0) / chartData.length;
|
||||||
|
|
||||||
|
// Get bar color based on type
|
||||||
|
const getBarColor = (type: string) => {
|
||||||
|
if (type === 'peak') return '#10b981'; // emerald-500
|
||||||
|
if (type === 'valley') return '#ef4444'; // red-500
|
||||||
|
return '#3b82f6'; // blue-500
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-slate-800 rounded-2xl p-8 shadow-lg">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
The Rise & Fall
|
||||||
|
</h2>
|
||||||
|
{timelineHeadline ? (
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">{timelineHeadline}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Your rating journey over time - peaks show your best moments, valleys reveal opportunities.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recharts Bar Chart */}
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 20, right: 20, left: 0, bottom: 40 }}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 11, fill: '#64748b' }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[1, 5]}
|
||||||
|
tick={{ fontSize: 11, fill: '#64748b' }}
|
||||||
|
tickCount={5}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-900 text-white text-sm rounded-lg px-3 py-2 shadow-lg">
|
||||||
|
<div className="font-medium">{data.date}</div>
|
||||||
|
<div className="text-slate-300">
|
||||||
|
{data.rating.toFixed(1)} ★ • {data.reviews} reviews
|
||||||
|
</div>
|
||||||
|
{data.change !== 0 && (
|
||||||
|
<div className={data.change > 0 ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{data.change > 0 ? '↑' : '↓'} {Math.abs(data.change).toFixed(2)} stars
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgRating}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
label={{ value: `Avg: ${avgRating.toFixed(1)}`, fill: '#94a3b8', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="rating" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={getBarColor(entry.type)} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter markers - significant events */}
|
||||||
|
{displayPoints.filter((p) => p.type !== 'normal').length > 0 && (
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
{displayPoints
|
||||||
|
.filter((p) => p.type !== 'normal')
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((point, index) => (
|
||||||
|
<div
|
||||||
|
key={point.date}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-lg
|
||||||
|
${point.type === 'peak'
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm
|
||||||
|
${point.type === 'peak' ? 'bg-emerald-500' : 'bg-red-500'}
|
||||||
|
`}>
|
||||||
|
{point.type === 'peak' ? '↑' : '↓'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{formatDate(point.date)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{point.rating?.toFixed(1)} ★
|
||||||
|
{point.change !== 0 && (
|
||||||
|
<span className={point.change > 0 ? 'text-emerald-600' : 'text-red-600'}>
|
||||||
|
{' '}({point.change > 0 ? '+' : ''}{point.change.toFixed(2)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BattleSectionProps {
|
||||||
|
strengths: StrengthItem[];
|
||||||
|
weaknesses: WeaknessItem[];
|
||||||
|
strengthsHeadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BattleSection({ strengths, weaknesses, strengthsHeadline }: BattleSectionProps) {
|
||||||
|
// Calculate total "force" on each side
|
||||||
|
const strengthForce = strengths.reduce((sum, s) => sum + s.span_count, 0);
|
||||||
|
const weaknessForce = weaknesses.reduce((sum, w) => sum + w.span_count, 0);
|
||||||
|
const totalForce = strengthForce + weaknessForce || 1;
|
||||||
|
|
||||||
|
const strengthPercent = (strengthForce / totalForce) * 100;
|
||||||
|
const weaknessPercent = (weaknessForce / totalForce) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-slate-800 rounded-2xl p-8 shadow-lg">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
The Battle for Stars
|
||||||
|
</h2>
|
||||||
|
{strengthsHeadline && (
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">{strengthsHeadline}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tug of war visualization */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="text-emerald-600 dark:text-emerald-400 font-bold text-lg">
|
||||||
|
Strengths
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-8 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-emerald-400 to-emerald-500 transition-all duration-1000 flex items-center justify-end pr-3"
|
||||||
|
style={{ width: `${strengthPercent}%` }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-sm font-bold">{strengthPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-red-500 to-red-400 transition-all duration-1000 flex items-center pl-3"
|
||||||
|
style={{ width: `${weaknessPercent}%` }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-sm font-bold">{weaknessPercent.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-red-600 dark:text-red-400 font-bold text-lg">
|
||||||
|
Weaknesses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center marker */}
|
||||||
|
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-1 h-8 bg-slate-400 dark:bg-slate-500 rounded mt-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two columns: Strengths vs Weaknesses */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
|
{/* Strengths Column */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-emerald-600 dark:text-emerald-400 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Forces Pulling Rating UP
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{strengths.slice(0, 5).map((strength, index) => {
|
||||||
|
const domainInfo = DOMAIN_FRIENDLY[strength.domain] || { emoji: '', label: strength.domain_name };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={strength.subcode}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-emerald-500 text-white flex items-center justify-center font-bold text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{strength.subcode_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{domainInfo.emoji} {domainInfo.label} | {strength.span_count} mentions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-emerald-600 dark:text-emerald-400 font-bold">
|
||||||
|
+{strength.positive_percentage.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weaknesses Column */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-red-600 dark:text-red-400 mb-4 flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Forces Pulling Rating DOWN
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{weaknesses.slice(0, 5).map((weakness, index) => {
|
||||||
|
const domainInfo = DOMAIN_FRIENDLY[weakness.domain] || { emoji: '', label: weakness.domain_name };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={weakness.subcode}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center font-bold text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{weakness.subcode_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{domainInfo.emoji} {domainInfo.label} | {weakness.span_count} mentions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-red-600 dark:text-red-400 font-bold">
|
||||||
|
-{weakness.negative_percentage.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
{weakness.projected_rating_impact !== null && (
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{weakness.projected_rating_impact > 0 ? '+' : ''}{weakness.projected_rating_impact.toFixed(2)} stars if fixed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomerVoicesSectionProps {
|
||||||
|
weaknesses: WeaknessItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerVoicesSection({ weaknesses }: CustomerVoicesSectionProps) {
|
||||||
|
// Collect all example spans from weaknesses
|
||||||
|
const allQuotes = weaknesses
|
||||||
|
.filter((w) => w.example_spans && w.example_spans.length > 0)
|
||||||
|
.flatMap((w) =>
|
||||||
|
(w.example_spans || []).map((span) => ({
|
||||||
|
text: span.span_text,
|
||||||
|
fullReview: span.review_text,
|
||||||
|
rating: span.rating,
|
||||||
|
date: span.review_date,
|
||||||
|
issue: w.subcode_name,
|
||||||
|
domain: w.domain_name,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
if (allQuotes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-gradient-to-br from-slate-100 to-slate-50 dark:from-slate-800 dark:to-slate-900 rounded-2xl p-8 shadow-lg">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
Customer Voices
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-8">
|
||||||
|
Real feedback from your customers - their words tell the story.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{allQuotes.map((quote, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="relative bg-white dark:bg-slate-800 rounded-xl p-6 shadow-md hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
{/* Quote mark */}
|
||||||
|
<div className="absolute -top-3 -left-2 text-6xl text-red-200 dark:text-red-900 font-serif leading-none">
|
||||||
|
“
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<p className="text-slate-700 dark:text-slate-300 mb-4 italic line-clamp-4">
|
||||||
|
{quote.text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{quote.rating !== null && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<svg
|
||||||
|
key={i}
|
||||||
|
className={`w-4 h-4 ${
|
||||||
|
i < quote.rating! ? 'text-yellow-400' : 'text-slate-300 dark:text-slate-600'
|
||||||
|
}`}
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="px-2 py-1 rounded-full text-xs bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400">
|
||||||
|
{quote.issue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quote.date && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500 dark:text-slate-500">
|
||||||
|
{new Date(quote.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionPlanSectionProps {
|
||||||
|
actions: ReportAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionPlanSection({ actions }: ActionPlanSectionProps) {
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-slate-800 rounded-2xl p-8 shadow-lg">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||||
|
The Action Plan
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-8">
|
||||||
|
Prioritized actions to improve your rating - tackle these in order for maximum impact.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`
|
||||||
|
relative overflow-hidden rounded-xl border-l-4
|
||||||
|
${action.priority === 'critical' ? 'border-red-500 bg-red-50 dark:bg-red-900/20' :
|
||||||
|
action.priority === 'high' ? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20' :
|
||||||
|
'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Priority indicator */}
|
||||||
|
<div className={`
|
||||||
|
flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-white font-bold
|
||||||
|
${getPriorityColor(action.priority)}
|
||||||
|
`}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||||
|
<h3 className="font-semibold text-lg text-slate-900 dark:text-white">
|
||||||
|
{action.action}
|
||||||
|
</h3>
|
||||||
|
<span className={`
|
||||||
|
px-2 py-0.5 rounded-full text-xs font-medium uppercase
|
||||||
|
${action.priority === 'critical' ? 'bg-red-500 text-white' :
|
||||||
|
action.priority === 'high' ? 'bg-orange-500 text-white' :
|
||||||
|
'bg-yellow-500 text-slate-900'}
|
||||||
|
`}>
|
||||||
|
{action.priority}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300">
|
||||||
|
{getEffortLabel(action.effort)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
{action.evidence}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 dark:text-slate-500 uppercase text-xs tracking-wider mb-1">
|
||||||
|
Owner
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{action.owner}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 dark:text-slate-500 uppercase text-xs tracking-wider mb-1">
|
||||||
|
Impact
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||||
|
+{action.impact_stars.toFixed(2)} stars
|
||||||
|
<svg className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 dark:text-slate-500 uppercase text-xs tracking-wider mb-1">
|
||||||
|
Complaints
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{action.complaint_count} affected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-500 dark:text-slate-500 uppercase text-xs tracking-wider mb-1">
|
||||||
|
Success Metric
|
||||||
|
</div>
|
||||||
|
<div className="font-medium text-slate-900 dark:text-white">
|
||||||
|
{action.success_metric}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress indicator decoration */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-1 bg-slate-200 dark:bg-slate-700">
|
||||||
|
<div
|
||||||
|
className={`h-full ${getPriorityColor(action.priority)} transition-all duration-1000`}
|
||||||
|
style={{ width: `${Math.min(100, (action.impact_stars / 0.5) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Main Component ====================
|
||||||
|
|
||||||
|
export function StoryView({ jobId, businessId }: StoryViewProps) {
|
||||||
|
// Fetch data using the shared hook with default filters
|
||||||
|
const { data, loading, error } = useReviewIQAnalytics({
|
||||||
|
jobId,
|
||||||
|
businessId,
|
||||||
|
filters: defaultFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data state
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-slate-600">No data available for this view.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key data
|
||||||
|
const synthesis = data.synthesis;
|
||||||
|
const insights = data.insights;
|
||||||
|
const timeline = data.timeline;
|
||||||
|
|
||||||
|
// Compute story points from timeline
|
||||||
|
const storyPoints = identifyStoryPoints(timeline);
|
||||||
|
|
||||||
|
// Get rating values (support both v1 and v2 synthesis)
|
||||||
|
const currentRating = getLegacyField(synthesis, 'current_rating') ?? data.overview.avg_rating ?? 0;
|
||||||
|
const potentialRating = getLegacyField(synthesis, 'potential_rating') ?? (currentRating + (insights.rating_simulator?.potential_gain ?? 0));
|
||||||
|
|
||||||
|
// Generate headline and emotional hook
|
||||||
|
const headline = getLegacyField(synthesis, 'headline') ?? "Your Customer Intelligence Story";
|
||||||
|
const emotionalHook = getEmotionalHook(currentRating, potentialRating);
|
||||||
|
|
||||||
|
// Get actions (only available in legacy format)
|
||||||
|
const actions = getLegacyField(synthesis, 'actions') ?? [];
|
||||||
|
|
||||||
|
// Get generated_at (available in both formats but in different locations)
|
||||||
|
const generatedAt = synthesis ? (isSynthesisV2(synthesis) ? synthesis.generated_at : synthesis.generated_at) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
{/* Section 1: The Hook */}
|
||||||
|
<HookSection
|
||||||
|
headline={headline}
|
||||||
|
currentRating={currentRating}
|
||||||
|
potentialRating={potentialRating}
|
||||||
|
emotionalHook={emotionalHook}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Section 2: The Rise & Fall (Timeline) */}
|
||||||
|
{timeline.length > 0 && (
|
||||||
|
<TimelineSection
|
||||||
|
storyPoints={storyPoints}
|
||||||
|
timelineHeadline={getLegacyField(synthesis, 'timeline_headline')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 3: The Battle (Strengths vs Weaknesses) */}
|
||||||
|
{(insights.strengths.length > 0 || insights.weaknesses.length > 0) && (
|
||||||
|
<BattleSection
|
||||||
|
strengths={insights.strengths}
|
||||||
|
weaknesses={insights.weaknesses}
|
||||||
|
strengthsHeadline={getLegacyField(synthesis, 'strengths_headline')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section 4: Customer Voices */}
|
||||||
|
<CustomerVoicesSection weaknesses={insights.weaknesses} />
|
||||||
|
|
||||||
|
{/* Section 5: The Action Plan */}
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<ActionPlanSection actions={actions} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer metadata */}
|
||||||
|
<div className="text-center text-sm text-slate-500 dark:text-slate-500 py-4">
|
||||||
|
<p>
|
||||||
|
Analysis based on {data.overview.total_reviews.toLocaleString()} reviews
|
||||||
|
{generatedAt && (
|
||||||
|
<> | Generated {new Date(generatedAt).toLocaleDateString()}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-75">
|
||||||
|
Job ID: {jobId} | Business ID: {businessId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StoryView;
|
||||||
@@ -152,56 +152,211 @@ export interface Insights {
|
|||||||
executive_summary: string;
|
executive_summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== AI Synthesis (Stage 4 Output) ====================
|
// ==================== Report Synthesis v2.0 (6-Section Business Report) ====================
|
||||||
|
|
||||||
export interface ActionItem {
|
// Section 1: Executive Summary
|
||||||
id: string;
|
export interface ExecutiveSummary {
|
||||||
title: string;
|
health_score: number; // 1-100 overall health
|
||||||
why: string; // Root cause from reviews
|
health_label: string; // "Needs Attention" | "Stable" | "Strong"
|
||||||
what: string; // Specific action to take
|
one_liner: string; // Single sentence verdict
|
||||||
who: string; // Department/role responsible
|
current_rating: number; // 3.71
|
||||||
impact: string; // Expected outcome
|
potential_rating: number; // 4.2
|
||||||
evidence: string[]; // Example review quotes
|
rating_gap: number; // 0.49
|
||||||
estimated_rating_lift: number | null;
|
estimated_revenue_at_risk: string; // "€15,000/month"
|
||||||
complexity: 'quick' | 'medium' | 'complex';
|
key_insight: string; // The most important finding
|
||||||
priority: 'critical' | 'high' | 'medium' | 'low';
|
momentum: 'improving' | 'declining' | 'stable';
|
||||||
timeline: string; // e.g., "This week", "This month"
|
momentum_detail: string; // Explanation
|
||||||
related_subcode: string; // URT subcode this addresses
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineAnnotation {
|
// Section 2: Risk Scorecard
|
||||||
date: string;
|
export interface RiskIndicator {
|
||||||
|
name: string; // "Staff Behavior"
|
||||||
|
score: number; // 1-10 (10 = excellent, 1 = critical)
|
||||||
|
trend: 'improving' | 'declining' | 'stable';
|
||||||
|
complaint_count: number; // Number of related complaints
|
||||||
|
color: 'green' | 'yellow' | 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskScorecard {
|
||||||
|
overall_risk: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
indicators: RiskIndicator[];
|
||||||
|
highest_risk_area: string; // "Value Perception"
|
||||||
|
immediate_attention: string; // What needs fixing NOW
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 3: Critical Issues
|
||||||
|
export interface CriticalIssue {
|
||||||
|
rank: number; // 1, 2, or 3
|
||||||
|
title: string; // "Hidden Fees Destroying Trust"
|
||||||
|
urt_code: string; // "V1.03"
|
||||||
|
complaint_count: number; // 94
|
||||||
|
revenue_impact: string; // "€12,000/month at risk"
|
||||||
|
evidence: string[]; // 2-3 damning quotes
|
||||||
|
root_cause: string; // Why this keeps happening
|
||||||
|
solution: string; // Specific fix
|
||||||
|
effort: 'quick_win' | 'moderate' | 'strategic';
|
||||||
|
timeline: string; // "1 week" | "2-4 weeks" | "1-2 months"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 4: Strengths to Protect
|
||||||
|
export interface StrengthToProtect {
|
||||||
|
title: string; // "Exceptional Staff Service"
|
||||||
|
mention_count: number; // 168
|
||||||
|
percentage: number; // 42.0 (% of positive reviews)
|
||||||
|
top_quotes: string[]; // 2-3 best quotes
|
||||||
|
risk_of_loss: string; // What could erode this strength
|
||||||
|
leverage_action: string; // How to amplify in marketing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 5: Action Matrix
|
||||||
|
export interface ActionMatrixItem {
|
||||||
|
action: string; // What to do (imperative)
|
||||||
|
owner: string; // Who owns it
|
||||||
|
effort: 'low' | 'medium' | 'high';
|
||||||
|
impact: 'low' | 'medium' | 'high';
|
||||||
|
quadrant: 'quick_win' | 'major_project' | 'fill_in' | 'deprioritize';
|
||||||
|
expected_lift: string; // "+0.3★"
|
||||||
|
deadline: string; // "Week 1" | "Week 2-4" | "Month 2-3"
|
||||||
|
success_metric: string; // Measurable KPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 6: 90-Day Tracking
|
||||||
|
export interface TrackingKPI {
|
||||||
|
metric: string; // "Deposit Complaints"
|
||||||
|
current_value: string; // "47/month"
|
||||||
|
target_30_day: string; // "< 25/month"
|
||||||
|
target_60_day: string; // "< 15/month"
|
||||||
|
target_90_day: string; // "< 5/month"
|
||||||
|
measurement: string; // How to measure this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart Data Types
|
||||||
|
export interface ChartDataPoint {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
value: number;
|
||||||
type: 'positive' | 'negative' | 'neutral' | 'event';
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Synthesis {
|
export interface TimeSeriesPoint {
|
||||||
// Narrative insights for each section
|
month: string; // "Jan", "Feb", etc.
|
||||||
executive_narrative: string; // Main story for exec summary
|
month_date: string; // "2025-01" for sorting
|
||||||
sentiment_insight: string; // Why sentiment is this way
|
value: number;
|
||||||
category_insight: string; // Pattern in categories
|
}
|
||||||
timeline_insight: string; // What's changing over time
|
|
||||||
|
|
||||||
// Highlights and focus areas
|
export interface DualSeriesPoint {
|
||||||
priority_domain: string | null; // Domain needing most attention
|
month: string;
|
||||||
priority_issue: string | null; // Issue to fix first
|
month_date: string;
|
||||||
|
positive: number;
|
||||||
|
negative: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Actionable recommendations
|
export interface ReportCharts {
|
||||||
action_plan: ActionItem[]; // Prioritized actions
|
rating_gauge: {
|
||||||
issue_actions: Record<string, string>; // issue_id → recommended action
|
current: number;
|
||||||
|
target: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
sentiment_pie: ChartDataPoint[];
|
||||||
|
issues_pie: ChartDataPoint[];
|
||||||
|
rating_distribution: ChartDataPoint[];
|
||||||
|
complaints_trend: TimeSeriesPoint[];
|
||||||
|
rating_trend: TimeSeriesPoint[];
|
||||||
|
momentum_trend: DualSeriesPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
// Timeline context
|
// Legacy types for backwards compatibility (v1.x reports)
|
||||||
timeline_annotations: TimelineAnnotation[];
|
export interface ReportAction {
|
||||||
|
priority: 'critical' | 'high' | 'medium';
|
||||||
|
action: string;
|
||||||
|
owner: string;
|
||||||
|
impact: string;
|
||||||
|
impact_stars: number;
|
||||||
|
effort: 'quick_win' | 'moderate' | 'strategic';
|
||||||
|
evidence: string;
|
||||||
|
complaint_count: number;
|
||||||
|
success_metric: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Marketing opportunities
|
export interface ReportEvidence {
|
||||||
marketing_angles: string[]; // Ways to promote strengths
|
quote: string;
|
||||||
|
context: string;
|
||||||
|
sentiment: 'damaging' | 'praising';
|
||||||
|
weight: 'critical' | 'notable';
|
||||||
|
}
|
||||||
|
|
||||||
// Competitor context (if available)
|
export interface ReportStrength {
|
||||||
competitor_context: string | null;
|
title: string;
|
||||||
|
mention_count: number;
|
||||||
|
quote: string;
|
||||||
|
marketing_angle: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Generated at
|
// Legacy Synthesis Interface (v1.x - for backwards compatibility)
|
||||||
|
export interface LegacySynthesis {
|
||||||
|
headline: string;
|
||||||
|
verdict: string;
|
||||||
|
current_rating: number;
|
||||||
|
potential_rating: number;
|
||||||
|
rating_gap: number;
|
||||||
|
narrative: string;
|
||||||
|
sentiment_headline: string;
|
||||||
|
category_headline: string;
|
||||||
|
timeline_headline: string;
|
||||||
|
strengths_headline: string;
|
||||||
|
primary_problem: string;
|
||||||
|
primary_problem_code: string;
|
||||||
|
root_cause: string;
|
||||||
|
actions: ReportAction[];
|
||||||
|
evidence: ReportEvidence[];
|
||||||
|
strengths: ReportStrength[];
|
||||||
|
momentum: 'improving' | 'declining' | 'stable';
|
||||||
|
momentum_detail: string;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
|
review_count: number;
|
||||||
|
insight_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Synthesis Interface (v2.0 - 6-Section Report)
|
||||||
|
export interface SynthesisV2 {
|
||||||
|
// Report metadata
|
||||||
|
report_version?: string; // "2.0"
|
||||||
|
report_title: string; // "Reputation Health Report: Soho Club"
|
||||||
|
report_date: string; // "January 2026"
|
||||||
|
business_name: string;
|
||||||
|
generated_at: string;
|
||||||
|
review_count: number;
|
||||||
|
insight_count: number;
|
||||||
|
analysis_period: string; // "Last 12 months"
|
||||||
|
|
||||||
|
// Section 1: Executive Summary
|
||||||
|
executive_summary: ExecutiveSummary;
|
||||||
|
|
||||||
|
// Section 2: Risk Scorecard
|
||||||
|
risk_scorecard: RiskScorecard;
|
||||||
|
|
||||||
|
// Section 3: Critical Issues (Top 3)
|
||||||
|
critical_issues: CriticalIssue[];
|
||||||
|
|
||||||
|
// Section 4: Protect Your Strengths
|
||||||
|
strengths: StrengthToProtect[];
|
||||||
|
|
||||||
|
// Section 5: Action Matrix
|
||||||
|
action_matrix: ActionMatrixItem[];
|
||||||
|
|
||||||
|
// Section 6: 90-Day Tracking Framework
|
||||||
|
tracking_kpis: TrackingKPI[];
|
||||||
|
|
||||||
|
// Charts data for visualization
|
||||||
|
charts?: ReportCharts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type that supports both formats
|
||||||
|
export type Synthesis = LegacySynthesis | SynthesisV2;
|
||||||
|
|
||||||
|
// Type guard to check if synthesis is v2 format
|
||||||
|
export function isSynthesisV2(synthesis: Synthesis): synthesis is SynthesisV2 {
|
||||||
|
return 'executive_summary' in synthesis && synthesis.executive_summary !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Issues (Enriched) ====================
|
// ==================== Issues (Enriched) ====================
|
||||||
@@ -314,6 +469,7 @@ export interface ReviewIQAnalyticsResponse {
|
|||||||
// ==================== Filter Types ====================
|
// ==================== Filter Types ====================
|
||||||
|
|
||||||
export type TimeRange = '7d' | '14d' | '30d' | '90d' | '1y' | 'all';
|
export type TimeRange = '7d' | '14d' | '30d' | '90d' | '1y' | 'all';
|
||||||
|
export type Granularity = 'day' | 'week' | 'month' | 'year';
|
||||||
export type Sentiment = 'positive' | 'neutral' | 'negative';
|
export type Sentiment = 'positive' | 'neutral' | 'negative';
|
||||||
export type URTDomain = 'O' | 'P' | 'J' | 'E' | 'A' | 'V' | 'R';
|
export type URTDomain = 'O' | 'P' | 'J' | 'E' | 'A' | 'V' | 'R';
|
||||||
export type Intensity = 'I1' | 'I2' | 'I3';
|
export type Intensity = 'I1' | 'I2' | 'I3';
|
||||||
|
|||||||
Reference in New Issue
Block a user