'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( 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 (
{/* Background decorative elements */}

{headline}

{/* Current Rating */}
{currentRating.toFixed(1)}
Current Rating
{/* Arrow */}
{/* Potential Rating */}
{potentialRating.toFixed(1)}
Potential Rating
{/* Gap Badge */} {gap > 0 && (
+{gap.toFixed(1)} stars possible
)}

“{emotionalHook}”

); } 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 (

The Rise & Fall

{timelineHeadline ? (

{timelineHeadline}

) : (

Your rating journey over time - peaks show your best moments, valleys reveal opportunities.

)} {/* Recharts Bar Chart */}
{ if (active && payload && payload.length) { const data = payload[0].payload; return (
{data.date}
{data.rating.toFixed(1)} ★ • {data.reviews} reviews
{data.change !== 0 && (
0 ? 'text-emerald-400' : 'text-red-400'}> {data.change > 0 ? '↑' : '↓'} {Math.abs(data.change).toFixed(2)} stars
)}
); } return null; }} /> {chartData.map((entry, index) => ( ))}
{/* Chapter markers - significant events */} {displayPoints.filter((p) => p.type !== 'normal').length > 0 && (
{displayPoints .filter((p) => p.type !== 'normal') .slice(0, 4) .map((point, index) => (
{point.type === 'peak' ? '↑' : '↓'}
{formatDate(point.date)}
{point.rating?.toFixed(1)} ★ {point.change !== 0 && ( 0 ? 'text-emerald-600' : 'text-red-600'}> {' '}({point.change > 0 ? '+' : ''}{point.change.toFixed(2)}) )}
))}
)}
); } 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 (

The Battle for Stars

{strengthsHeadline && (

{strengthsHeadline}

)} {/* Tug of war visualization */}
Strengths
{strengthPercent.toFixed(0)}%
{weaknessPercent.toFixed(0)}%
Weaknesses
{/* Center marker */}
{/* Two columns: Strengths vs Weaknesses */}
{/* Strengths Column */}

Forces Pulling Rating UP

{strengths.slice(0, 5).map((strength, index) => { const domainInfo = DOMAIN_FRIENDLY[strength.domain] || { emoji: '', label: strength.domain_name }; return (
{index + 1}
{strength.subcode_name}
{domainInfo.emoji} {domainInfo.label} | {strength.span_count} mentions
+{strength.positive_percentage.toFixed(0)}%
); })}
{/* Weaknesses Column */}

Forces Pulling Rating DOWN

{weaknesses.slice(0, 5).map((weakness, index) => { const domainInfo = DOMAIN_FRIENDLY[weakness.domain] || { emoji: '', label: weakness.domain_name }; return (
{index + 1}
{weakness.subcode_name}
{domainInfo.emoji} {domainInfo.label} | {weakness.span_count} mentions
-{weakness.negative_percentage.toFixed(0)}%
{weakness.projected_rating_impact !== null && (
{weakness.projected_rating_impact > 0 ? '+' : ''}{weakness.projected_rating_impact.toFixed(2)} stars if fixed
)}
); })}
); } 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 (

Customer Voices

Real feedback from your customers - their words tell the story.

{allQuotes.map((quote, index) => (
{/* Quote mark */}

{quote.text}

{quote.rating !== null && (
{[...Array(5)].map((_, i) => ( ))}
)}
{quote.issue}
{quote.date && (
{new Date(quote.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
)}
))}
); } interface ActionPlanSectionProps { actions: ReportAction[]; } function ActionPlanSection({ actions }: ActionPlanSectionProps) { if (actions.length === 0) { return null; } return (

The Action Plan

Prioritized actions to improve your rating - tackle these in order for maximum impact.

{actions.map((action, index) => (
{/* Priority indicator */}
{index + 1}

{action.action}

{action.priority} {getEffortLabel(action.effort)}

{action.evidence}

Owner
{action.owner}
Impact
+{action.impact_stars.toFixed(2)} stars
Complaints
{action.complaint_count} affected
Success Metric
{action.success_metric}
{/* Progress indicator decoration */}
))}
); } // ==================== 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 (
); } // Error state if (error) { return (

{error}

); } // No data state if (!data) { return (

No data available for this view.

); } // 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 (
{/* Section 1: The Hook */} {/* Section 2: The Rise & Fall (Timeline) */} {timeline.length > 0 && ( )} {/* Section 3: The Battle (Strengths vs Weaknesses) */} {(insights.strengths.length > 0 || insights.weaknesses.length > 0) && ( )} {/* Section 4: Customer Voices */} {/* Section 5: The Action Plan */} {actions.length > 0 && ( )} {/* Footer metadata */}

Analysis based on {data.overview.total_reviews.toLocaleString()} reviews {generatedAt && ( <> | Generated {new Date(generatedAt).toLocaleDateString()} )}

Job ID: {jobId} | Business ID: {businessId}

); } export default StoryView;