- 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>
831 lines
31 KiB
TypeScript
831 lines
31 KiB
TypeScript
'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;
|