v3
This commit is contained in:
@@ -0,0 +1,844 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Globe,
|
||||
FileText,
|
||||
Code,
|
||||
Shield,
|
||||
// Search, // Not used in current implementation
|
||||
Zap,
|
||||
Database,
|
||||
// Lock, // Not used in current implementation
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Sparkles,
|
||||
FileCode,
|
||||
Network,
|
||||
Info,
|
||||
Eye
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ScoreChart from "./ScoreChart";
|
||||
import RadarChart from "./RadarChart";
|
||||
import MetricBars from "./MetricBars";
|
||||
|
||||
interface ControlPanelProps {
|
||||
isAnalyzing: boolean;
|
||||
showResults: boolean;
|
||||
url: string;
|
||||
analysisData?: any;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
interface CheckItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
status: 'pending' | 'checking' | 'pass' | 'fail' | 'warning';
|
||||
score?: number;
|
||||
details?: string;
|
||||
recommendation?: string;
|
||||
actionItems?: string[];
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export default function ControlPanel({
|
||||
isAnalyzing,
|
||||
showResults,
|
||||
url,
|
||||
analysisData,
|
||||
onReset,
|
||||
}: ControlPanelProps) {
|
||||
const [showAIAnalysis, setShowAIAnalysis] = useState(false); // Reserved for AI analysis feature
|
||||
const [aiInsights, setAiInsights] = useState<CheckItem[]>([]);
|
||||
const [isAnalyzingAI, setIsAnalyzingAI] = useState(false);
|
||||
const [combinedChecks, setCombinedChecks] = useState<CheckItem[]>([]);
|
||||
const [checks, setChecks] = useState<CheckItem[]>([
|
||||
{
|
||||
id: 'heading-structure',
|
||||
label: 'Heading Hierarchy',
|
||||
description: 'H1-H6 structure',
|
||||
icon: FileText,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'readability',
|
||||
label: 'Readability',
|
||||
description: 'Content clarity',
|
||||
icon: Globe,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'meta-tags',
|
||||
label: 'Metadata Quality',
|
||||
description: 'Title, desc, author',
|
||||
icon: FileCode,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'semantic-html',
|
||||
label: 'Semantic HTML',
|
||||
description: 'Proper HTML5 tags',
|
||||
icon: Code,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'accessibility',
|
||||
label: 'Accessibility',
|
||||
description: 'Alt text & ARIA',
|
||||
icon: Eye,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'llms-txt',
|
||||
label: 'LLMs.txt',
|
||||
description: 'AI permissions',
|
||||
icon: Bot,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'robots-txt',
|
||||
label: 'Robots.txt',
|
||||
description: 'Crawler rules',
|
||||
icon: Shield,
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'sitemap',
|
||||
label: 'Sitemap',
|
||||
description: 'Site structure',
|
||||
icon: Network,
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
|
||||
const [overallScore, setOverallScore] = useState(0);
|
||||
const [currentCheckIndex, setCurrentCheckIndex] = useState(-1);
|
||||
const [selectedCheck, setSelectedCheck] = useState<string | null>(null);
|
||||
const [hoveredCheck, setHoveredCheck] = useState<string | null>(null);
|
||||
const [enhancedScore, setEnhancedScore] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'chart' | 'bars'>('grid');
|
||||
|
||||
useEffect(() => {
|
||||
if (analysisData && analysisData.checks && showResults) {
|
||||
// Use real data from API
|
||||
const mappedChecks = analysisData.checks.map((check: any) => ({
|
||||
...check,
|
||||
icon: checks.find(c => c.id === check.id)?.icon || FileText,
|
||||
description: check.details || checks.find(c => c.id === check.id)?.description,
|
||||
}));
|
||||
setChecks(mappedChecks);
|
||||
setCombinedChecks(mappedChecks); // Initialize with basic checks
|
||||
setOverallScore(analysisData.overallScore || 0);
|
||||
setCurrentCheckIndex(-1);
|
||||
|
||||
// If AI analysis should auto-start, handle the promise
|
||||
if (analysisData.autoStartAI && analysisData.aiAnalysisPromise) {
|
||||
console.log('Auto-starting AI analysis with promise');
|
||||
setIsAnalyzingAI(true);
|
||||
setShowAIAnalysis(true);
|
||||
|
||||
// Add placeholder AI tiles immediately with actual titles
|
||||
const placeholderAIChecks = [
|
||||
{
|
||||
id: 'ai-loading-0',
|
||||
label: 'Content Quality for AI',
|
||||
description: 'Analyzing content signal ratio...',
|
||||
icon: Sparkles,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-1',
|
||||
label: 'Information Architecture',
|
||||
description: 'Evaluating page structure...',
|
||||
icon: Bot,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-2',
|
||||
label: 'Crawlability Patterns',
|
||||
description: 'Checking JavaScript usage...',
|
||||
icon: Database,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-3',
|
||||
label: 'AI Training Value',
|
||||
description: 'Assessing training potential...',
|
||||
icon: Network,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-4',
|
||||
label: 'Knowledge Extraction',
|
||||
description: 'Analyzing entity definitions...',
|
||||
icon: FileCode,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-5',
|
||||
label: 'Template Quality',
|
||||
description: 'Reviewing semantic structure...',
|
||||
icon: Shield,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-6',
|
||||
label: 'Content Depth',
|
||||
description: 'Measuring content richness...',
|
||||
icon: Zap,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-7',
|
||||
label: 'Machine Readability',
|
||||
description: 'Testing extraction reliability...',
|
||||
icon: Globe,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
}
|
||||
];
|
||||
|
||||
// Add loading AI tiles with staggered animation
|
||||
placeholderAIChecks.forEach((check, idx) => {
|
||||
setTimeout(() => {
|
||||
setCombinedChecks(prev => [...prev, check]);
|
||||
}, 100 * (idx + 1));
|
||||
});
|
||||
|
||||
// Handle the AI analysis promise
|
||||
analysisData.aiAnalysisPromise
|
||||
.then(async (aiResponse: any) => {
|
||||
if (aiResponse) {
|
||||
const data = await aiResponse.json();
|
||||
if (data.success && data.insights) {
|
||||
// Convert AI insights to CheckItem format
|
||||
const aiChecks: CheckItem[] = data.insights.map((insight: any, idx: number) => ({
|
||||
...insight,
|
||||
icon: [Sparkles, Bot, Database, Network, FileCode, Shield, Zap, Globe][idx % 8],
|
||||
description: insight.details?.substring(0, 60) + '...' || 'AI Analysis',
|
||||
isAI: true,
|
||||
}));
|
||||
|
||||
setAiInsights(aiChecks);
|
||||
|
||||
// Replace loading tiles with real AI tiles
|
||||
setCombinedChecks(prev => {
|
||||
// Remove loading tiles
|
||||
const withoutLoading = prev.filter(c => !(c as any).isLoading);
|
||||
// Add real AI tiles
|
||||
return [...withoutLoading, ...aiChecks];
|
||||
});
|
||||
|
||||
// Calculate enhanced score
|
||||
if (data.insights.length > 0) {
|
||||
const aiScores = data.insights.map((i: any) => i.score || 0);
|
||||
const avgAiScore = aiScores.reduce((a: number, b: number) => a + b, 0) / aiScores.length;
|
||||
const combinedScore = Math.round((overallScore * 0.6) + (avgAiScore * 0.4));
|
||||
setEnhancedScore(combinedScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error('AI analysis error:', error);
|
||||
// Remove loading tiles on error
|
||||
setCombinedChecks(prev => prev.filter(c => !(c as any).isLoading));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsAnalyzingAI(false);
|
||||
});
|
||||
}
|
||||
} else if (isAnalyzing) {
|
||||
// Reset all checks when starting analysis
|
||||
const resetChecks = checks.map(check => ({ ...check, status: 'pending' as const }));
|
||||
setChecks(resetChecks);
|
||||
setCombinedChecks(resetChecks); // Reset combined checks too
|
||||
setCurrentCheckIndex(0);
|
||||
setOverallScore(0);
|
||||
|
||||
// Visual animation while waiting for real results
|
||||
const checkInterval = setInterval(() => {
|
||||
setCurrentCheckIndex(prev => {
|
||||
if (prev >= checks.length - 1) {
|
||||
clearInterval(checkInterval);
|
||||
return prev;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(checkInterval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAnalyzing, showResults, analysisData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentCheckIndex >= 0 && currentCheckIndex < checks.length && isAnalyzing) {
|
||||
// Mark current as checking during animation
|
||||
setChecks(prev => prev.map((check, index) => {
|
||||
if (index === currentCheckIndex) {
|
||||
return { ...check, status: 'checking' };
|
||||
}
|
||||
if (index < currentCheckIndex) {
|
||||
return { ...check, status: 'checking' };
|
||||
}
|
||||
return check;
|
||||
}));
|
||||
|
||||
// Update combinedChecks to show the animation
|
||||
setCombinedChecks(prev => prev.map((check, index) => {
|
||||
if (index === currentCheckIndex) {
|
||||
return { ...check, status: 'checking' };
|
||||
}
|
||||
if (index < currentCheckIndex) {
|
||||
return { ...check, status: 'checking' };
|
||||
}
|
||||
return check;
|
||||
}));
|
||||
}
|
||||
}, [currentCheckIndex, checks.length, isAnalyzing]);
|
||||
|
||||
const getStatusIcon = (status: CheckItem['status']) => {
|
||||
switch (status) {
|
||||
case 'checking':
|
||||
return <Loader2 className="w-16 h-16 text-heat-100 animate-spin" />;
|
||||
case 'pass':
|
||||
return <CheckCircle2 className="w-16 h-16 text-accent-black" />;
|
||||
case 'fail':
|
||||
return <XCircle className="w-16 h-16 text-heat-200" />;
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-16 h-16 text-heat-100" />;
|
||||
default:
|
||||
return <div className="w-16 h-16 rounded-full border border-black-alpha-8" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function available but not used in current render
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return "text-accent-black";
|
||||
if (score >= 60) return "text-accent-black";
|
||||
return "text-accent-black";
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-[1200px] mx-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="text-center mb-48 pt-24 md:pt-0"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-title-h2 text-accent-black mb-12">AI Readiness Analysis</h2>
|
||||
<p className="text-body-large text-black-alpha-64">Single-page snapshot of {url}</p>
|
||||
|
||||
{showResults && (
|
||||
<>
|
||||
{/* View Mode Toggle - Moved above score */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-24 mb-20 flex justify-center gap-4"
|
||||
>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`px-16 py-8 rounded-8 text-label-medium font-medium transition-all ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-accent-black text-white shadow-md'
|
||||
: 'bg-black-alpha-4 text-black-alpha-64 hover:bg-black-alpha-8'
|
||||
}`}
|
||||
>
|
||||
Grid View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('chart')}
|
||||
className={`px-16 py-8 rounded-8 text-label-medium font-medium transition-all ${
|
||||
viewMode === 'chart'
|
||||
? 'bg-accent-black text-white shadow-md'
|
||||
: 'bg-black-alpha-4 text-black-alpha-64 hover:bg-black-alpha-8'
|
||||
}`}
|
||||
>
|
||||
Radar Chart
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('bars')}
|
||||
className={`px-16 py-8 rounded-8 text-label-medium font-medium transition-all ${
|
||||
viewMode === 'bars'
|
||||
? 'bg-accent-black text-white shadow-md'
|
||||
: 'bg-black-alpha-4 text-black-alpha-64 hover:bg-black-alpha-8'
|
||||
}`}
|
||||
>
|
||||
Bar Chart
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", delay: 0.5 }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<ScoreChart
|
||||
score={enhancedScore > 0 ? enhancedScore : overallScore}
|
||||
enhanced={enhancedScore > 0}
|
||||
size={180}
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Conditional rendering based on view mode */}
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-12 mb-40 px-40 relative">
|
||||
{combinedChecks.map((check, index) => {
|
||||
const isActive = index === currentCheckIndex;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={check.id}
|
||||
initial={(check as any).isAI ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.9 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: isActive ? 1.05 : 1,
|
||||
}}
|
||||
transition={{
|
||||
delay: (check as any).isAI ? 0 : index * 0.1,
|
||||
scale: { type: "spring", stiffness: 300 }
|
||||
}}
|
||||
className={`
|
||||
relative p-16 rounded-8 transition-all bg-accent-white border
|
||||
${(check as any).isAI ? 'border-heat-100 border-opacity-40 bg-gradient-to-br from-accent-white to-heat-4' : 'border-black-alpha-8'}
|
||||
${isActive ? 'border-heat-100 shadow-lg' : ''}
|
||||
${check.status !== 'pending' && check.status !== 'checking' ? 'cursor-pointer hover:shadow-md' : ''}
|
||||
${(check as any).isLoading ? 'animate-pulse' : ''}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (check.status !== 'pending' && check.status !== 'checking') {
|
||||
setSelectedCheck(selectedCheck === check.id ? null : check.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCheck(check.id)}
|
||||
onMouseLeave={() => setHoveredCheck(null)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-end mb-12">
|
||||
{getStatusIcon(check.status)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-label-large mb-4 text-accent-black font-medium flex items-center gap-6">
|
||||
{check.label}
|
||||
{check.tooltip && !aiInsights.some(ai => ai.id === check.id) && (
|
||||
<div className="relative inline-block">
|
||||
<Info className="w-14 h-14 text-black-alpha-32 hover:text-black-alpha-64 transition-colors" />
|
||||
<AnimatePresence>
|
||||
{hoveredCheck === check.id && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 5 }}
|
||||
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-8 w-200 p-8 bg-accent-black text-white text-body-x-small rounded-6 shadow-lg z-50 pointer-events-none"
|
||||
>
|
||||
{check.tooltip}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-accent-black" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<p className="text-body-small text-black-alpha-64">
|
||||
{check.description}
|
||||
</p>
|
||||
|
||||
{check.status !== 'pending' && check.status !== 'checking' && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-8"
|
||||
>
|
||||
<div className="h-2 bg-black-alpha-4 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className={`
|
||||
h-full rounded-full
|
||||
${check.status === 'pass' ? 'bg-accent-black' : ''}
|
||||
${check.status === 'warning' ? 'bg-heat-100' : ''}
|
||||
${check.status === 'fail' ? 'bg-heat-200' : ''}
|
||||
`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${check.score}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-label-x-small text-black-alpha-32 mt-4 text-center"
|
||||
>
|
||||
Click for details
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
<AnimatePresence>
|
||||
{selectedCheck === check.id && check.details && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mt-12 pt-12 border-t border-black-alpha-8"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-48 mb-2">Status</div>
|
||||
<div className="text-body-small text-accent-black">{check.details}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-48 mb-2">Recommendation</div>
|
||||
<div className="text-body-small text-black-alpha-64">{check.recommendation}</div>
|
||||
{check.actionItems && check.actionItems.length > 0 && (
|
||||
<ul className="mt-4 space-y-2">
|
||||
{check.actionItems.map((item: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-6 text-body-small text-black-alpha-64">
|
||||
<span className="text-heat-100 mt-1">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Radar Chart View */}
|
||||
{viewMode === 'chart' && showResults && (
|
||||
<div>
|
||||
<motion.div
|
||||
className="flex justify-center gap-40 mb-40"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Basic Analysis Chart */}
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-label-large text-accent-black mb-16 font-medium">Basic Analysis</h3>
|
||||
<RadarChart
|
||||
data={checks
|
||||
.filter(check => check.status !== 'pending' && check.status !== 'checking')
|
||||
.slice(0, 8)
|
||||
.map(check => ({
|
||||
label: check.label.length > 12 ? check.label.substring(0, 12) + '...' : check.label,
|
||||
score: check.score || 0
|
||||
}))}
|
||||
size={350}
|
||||
/>
|
||||
<div className="mt-16 text-center">
|
||||
<div className="text-title-h3 text-accent-black">{overallScore}%</div>
|
||||
<div className="text-label-small text-black-alpha-48">Overall Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VS Indicator */}
|
||||
{aiInsights.length > 0 && (
|
||||
<motion.div
|
||||
className="flex items-center"
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring" }}
|
||||
>
|
||||
<div className="text-label-large text-black-alpha-32 font-medium">VS</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* AI Analysis Chart - Only show if AI insights exist */}
|
||||
{aiInsights.length > 0 && (
|
||||
<motion.div
|
||||
className="flex flex-col items-center"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h3 className="text-label-large text-heat-100 mb-16 font-medium">AI Enhanced Analysis</h3>
|
||||
<RadarChart
|
||||
data={aiInsights
|
||||
.filter(check => check.status !== 'pending' && check.status !== 'checking')
|
||||
.slice(0, 8)
|
||||
.map(check => ({
|
||||
label: check.label.length > 12 ? check.label.substring(0, 12) + '...' : check.label,
|
||||
score: check.score || 0
|
||||
}))}
|
||||
size={350}
|
||||
/>
|
||||
<div className="mt-16 text-center">
|
||||
<div className="text-title-h3 text-heat-100">
|
||||
{Math.round(aiInsights.reduce((sum, check) => sum + (check.score || 0), 0) / aiInsights.length)}%
|
||||
</div>
|
||||
<div className="text-label-small text-heat-100 opacity-60">AI Score</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Comparison Summary */}
|
||||
{aiInsights.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<div className="inline-flex items-center gap-8 px-16 py-8 bg-heat-4 rounded-8">
|
||||
<span className="text-label-medium text-accent-black">
|
||||
AI analysis found {aiInsights.filter(i => i.score && i.score < 50).length} additional areas for improvement
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bar Chart View */}
|
||||
{viewMode === 'bars' && showResults && (
|
||||
<motion.div
|
||||
className="px-40 mb-40"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MetricBars
|
||||
metrics={combinedChecks
|
||||
.filter(check => check.status !== 'pending' && check.status !== 'checking')
|
||||
.map(check => ({
|
||||
label: check.label,
|
||||
score: check.score || 0,
|
||||
status: check.status as 'pass' | 'warning' | 'fail',
|
||||
category: (check as any).isAI ? 'ai' :
|
||||
['robots-txt', 'sitemap', 'llms-txt'].includes(check.id) ? 'domain' : 'page',
|
||||
details: check.details,
|
||||
recommendation: check.recommendation,
|
||||
actionItems: check.actionItems
|
||||
}))}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{showResults && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="flex gap-12 justify-center"
|
||||
>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-20 py-10 bg-accent-white border border-black-alpha-8 hover:bg-black-alpha-4 rounded-8 text-label-medium transition-all"
|
||||
>
|
||||
Analyze Another Site
|
||||
</button>
|
||||
{true && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsAnalyzingAI(true);
|
||||
setShowAIAnalysis(true);
|
||||
|
||||
// Add placeholder AI tiles immediately with actual titles
|
||||
const placeholderAIChecks = [
|
||||
{
|
||||
id: 'ai-loading-0',
|
||||
label: 'Content Quality for AI',
|
||||
description: 'Analyzing content signal ratio...',
|
||||
icon: Sparkles,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-1',
|
||||
label: 'Information Architecture',
|
||||
description: 'Evaluating page structure...',
|
||||
icon: Bot,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-2',
|
||||
label: 'Crawlability Patterns',
|
||||
description: 'Checking JavaScript usage...',
|
||||
icon: Database,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-3',
|
||||
label: 'AI Training Value',
|
||||
description: 'Assessing training potential...',
|
||||
icon: Network,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-4',
|
||||
label: 'Knowledge Extraction',
|
||||
description: 'Analyzing entity definitions...',
|
||||
icon: FileCode,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-5',
|
||||
label: 'Template Quality',
|
||||
description: 'Reviewing semantic structure...',
|
||||
icon: Shield,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-6',
|
||||
label: 'Content Depth',
|
||||
description: 'Measuring content richness...',
|
||||
icon: Zap,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
},
|
||||
{
|
||||
id: 'ai-loading-7',
|
||||
label: 'Machine Readability',
|
||||
description: 'Testing extraction reliability...',
|
||||
icon: Globe,
|
||||
status: 'checking' as const,
|
||||
score: 0,
|
||||
isAI: true,
|
||||
isLoading: true
|
||||
}
|
||||
];
|
||||
|
||||
// Add loading AI tiles with staggered animation immediately
|
||||
placeholderAIChecks.forEach((check, idx) => {
|
||||
setTimeout(() => {
|
||||
setCombinedChecks(prev => [...prev, check]);
|
||||
}, 100 * (idx + 1));
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai-analysis', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
htmlContent: analysisData?.htmlContent || '',
|
||||
currentChecks: checks
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.insights) {
|
||||
// Convert AI insights to CheckItem format with AI flag
|
||||
const aiChecks: CheckItem[] = data.insights.map((insight: any, idx: number) => ({
|
||||
...insight,
|
||||
icon: [Sparkles, Bot, Database, Network, FileCode, Shield, Zap, Globe][idx % 8],
|
||||
description: insight.details?.substring(0, 60) + '...' || 'AI Analysis',
|
||||
isAI: true, // Mark as AI-generated
|
||||
}));
|
||||
|
||||
setAiInsights(aiChecks);
|
||||
|
||||
// Replace loading tiles with real AI tiles
|
||||
setCombinedChecks(prev => {
|
||||
// Remove loading tiles
|
||||
const withoutLoading = prev.filter(c => !(c as any).isLoading);
|
||||
// Add real AI tiles
|
||||
return [...withoutLoading, ...aiChecks];
|
||||
});
|
||||
|
||||
// Calculate enhanced score
|
||||
if (data.insights.length > 0) {
|
||||
const aiScores = data.insights.map((i: any) => i.score || 0);
|
||||
const avgAiScore = aiScores.reduce((a: number, b: number) => a + b, 0) / aiScores.length;
|
||||
const combinedScore = Math.round((overallScore * 0.6) + (avgAiScore * 0.4));
|
||||
setEnhancedScore(combinedScore);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI analysis error:', error);
|
||||
// Remove loading tiles on error
|
||||
setCombinedChecks(prev => prev.filter(c => !(c as any).isLoading));
|
||||
} finally {
|
||||
setIsAnalyzingAI(false);
|
||||
}
|
||||
}}
|
||||
disabled={isAnalyzingAI}
|
||||
className="px-20 py-10 bg-accent-black hover:bg-black-alpha-80 text-white rounded-8 text-label-medium transition-all disabled:opacity-50"
|
||||
>
|
||||
{isAnalyzingAI ? 'Analyzing...' : 'Analyze with AI'}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Check, X, FileText, Globe, Code, Sparkles, AlertCircle } from "lucide-react";
|
||||
// import { Zap, Shield } from "lucide-react"; // Reserved for future features
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface InlineResultsProps {
|
||||
isAnalyzing: boolean;
|
||||
showResults: boolean;
|
||||
analysisStep: number;
|
||||
url: string;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const analysisSteps = [
|
||||
"Fetching website content...",
|
||||
"Checking for LLMs.txt...",
|
||||
"Analyzing HTML structure...",
|
||||
"Calculating AI readiness...",
|
||||
];
|
||||
|
||||
// Placeholder data for the results
|
||||
const mockResults = {
|
||||
score: 78,
|
||||
grade: "B+",
|
||||
llmsTxt: true,
|
||||
robotsTxt: true,
|
||||
structuredData: true,
|
||||
semanticHTML: false,
|
||||
metaTags: true,
|
||||
accessibility: true,
|
||||
};
|
||||
|
||||
export default function InlineResults({
|
||||
isAnalyzing,
|
||||
showResults,
|
||||
analysisStep,
|
||||
url: _url, // URL prop available but not used in current implementation
|
||||
onReset,
|
||||
}: InlineResultsProps) {
|
||||
const [displayScore, setDisplayScore] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (showResults) {
|
||||
// Animate score counting up
|
||||
const target = mockResults.score;
|
||||
const duration = 1500;
|
||||
const increment = target / (duration / 16);
|
||||
let current = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
current += increment;
|
||||
if (current >= target) {
|
||||
setDisplayScore(target);
|
||||
clearInterval(timer);
|
||||
} else {
|
||||
setDisplayScore(Math.floor(current));
|
||||
}
|
||||
}, 16);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [showResults]);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return "#22c55e";
|
||||
if (score >= 60) return "#eab308";
|
||||
return "#ef4444";
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{/* Analyzing State */}
|
||||
{isAnalyzing && (
|
||||
<motion.div
|
||||
key="analyzing"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-20"
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div className="relative">
|
||||
<div className="h-2 bg-black-alpha-4 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-heat-100 to-heat-200"
|
||||
initial={{ width: "0%" }}
|
||||
animate={{ width: `${((analysisStep + 1) / 4) * 100}%` }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Glowing dot at the end of progress */}
|
||||
<motion.div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-heat-100 rounded-full"
|
||||
style={{
|
||||
left: `${((analysisStep + 1) / 4) * 100}%`,
|
||||
boxShadow: "0 0 20px rgba(255, 77, 0, 0.8)",
|
||||
}}
|
||||
animate={{
|
||||
scale: [1, 1.5, 1],
|
||||
opacity: [1, 0.8, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Text */}
|
||||
<motion.div
|
||||
key={analysisStep}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-8 text-body-medium text-black-alpha-64"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Sparkles className="w-16 h-16 text-heat-100" />
|
||||
</motion.div>
|
||||
{analysisSteps[analysisStep]}
|
||||
</motion.div>
|
||||
|
||||
{/* ASCII Animation */}
|
||||
<motion.div
|
||||
className="font-mono text-xs text-black-alpha-16 overflow-hidden h-32 relative"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
{'< analyzing />'}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Results State */}
|
||||
{showResults && (
|
||||
<motion.div
|
||||
key="results"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="space-y-24"
|
||||
>
|
||||
{/* Score Display */}
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
delay: 0.2
|
||||
}}
|
||||
className="relative inline-block"
|
||||
>
|
||||
{/* Background glow */}
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full blur-xl"
|
||||
style={{ background: getScoreColor(mockResults.score) }}
|
||||
animate={{
|
||||
opacity: [0.3, 0.6, 0.3],
|
||||
scale: [0.8, 1.2, 0.8],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Score circle */}
|
||||
<div
|
||||
className="relative w-120 h-120 rounded-full flex flex-col items-center justify-center"
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, ${getScoreColor(mockResults.score)} ${displayScore * 3.6}deg, #f0f0f0 ${displayScore * 3.6}deg)`,
|
||||
padding: "4px",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full bg-white rounded-full flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: getScoreColor(mockResults.score) }}
|
||||
>
|
||||
{displayScore}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="text-label-medium text-black-alpha-48"
|
||||
>
|
||||
AI Ready
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Checks Grid */}
|
||||
<motion.div
|
||||
className="grid grid-cols-3 gap-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
{[
|
||||
{
|
||||
label: "LLMs.txt",
|
||||
value: mockResults.llmsTxt,
|
||||
icon: FileText,
|
||||
description: "AI instructions",
|
||||
detail: mockResults.llmsTxt ? "Found" : "Missing"
|
||||
},
|
||||
{
|
||||
label: "Structured Data",
|
||||
value: mockResults.structuredData,
|
||||
icon: Code,
|
||||
description: "Schema markup",
|
||||
detail: mockResults.structuredData ? "Detected" : "Not found"
|
||||
},
|
||||
{
|
||||
label: "Semantic HTML",
|
||||
value: mockResults.semanticHTML,
|
||||
icon: Globe,
|
||||
description: "HTML5 tags",
|
||||
detail: mockResults.semanticHTML ? "Good" : "Needs work"
|
||||
},
|
||||
].map((item, index) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.9 + index * 0.1 }}
|
||||
className={`
|
||||
relative p-16 rounded-12 transition-all hover:shadow-md cursor-pointer
|
||||
${item.value
|
||||
? 'bg-gradient-to-br from-green-50 to-green-100/50 border-green-200'
|
||||
: 'bg-gradient-to-br from-red-50 to-red-100/50 border-red-200'}
|
||||
border
|
||||
`}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute top-12 right-12">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 1 + index * 0.1, type: "spring" }}
|
||||
className={`
|
||||
w-24 h-24 rounded-full flex items-center justify-center
|
||||
${item.value ? 'bg-green-500' : 'bg-red-500'}
|
||||
`}
|
||||
>
|
||||
{item.value ? (
|
||||
<Check className="w-14 h-14 text-white" strokeWidth={3} />
|
||||
) : (
|
||||
<X className="w-14 h-14 text-white" strokeWidth={3} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="mb-12">
|
||||
<item.icon className={`
|
||||
w-24 h-24
|
||||
${item.value ? 'text-green-600' : 'text-red-600'}
|
||||
`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-label-medium text-accent-black">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-body-small text-black-alpha-48">
|
||||
{item.description}
|
||||
</div>
|
||||
<div className={`
|
||||
text-label-small font-semibold
|
||||
${item.value ? 'text-green-600' : 'text-red-600'}
|
||||
`}>
|
||||
{item.detail}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Tip */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.2 }}
|
||||
className="p-12 bg-heat-4 rounded-8 border border-heat-100 flex items-start gap-8"
|
||||
>
|
||||
<AlertCircle className="w-16 h-16 text-heat-100 mt-2" />
|
||||
<div className="flex-1">
|
||||
<div className="text-label-medium text-accent-black mb-4">Quick Tip</div>
|
||||
<div className="text-body-small text-black-alpha-64">
|
||||
{mockResults.semanticHTML
|
||||
? "Your site has good semantic HTML structure for AI understanding."
|
||||
: "Add semantic HTML5 elements to improve AI comprehension of your content."}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.4 }}
|
||||
className="flex gap-8"
|
||||
>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex-1 px-16 py-10 bg-black-alpha-4 hover:bg-black-alpha-6 rounded-8 text-label-medium transition-all"
|
||||
>
|
||||
Try Another
|
||||
</button>
|
||||
<button className="flex-1 px-16 py-10 bg-heat-100 hover:bg-heat-200 text-white rounded-8 text-label-medium transition-all shadow-lg hover:shadow-xl">
|
||||
View Details
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface MetricBarsProps {
|
||||
metrics: {
|
||||
label: string;
|
||||
score: number;
|
||||
status: 'pass' | 'warning' | 'fail';
|
||||
category?: 'page' | 'domain' | 'ai';
|
||||
details?: string;
|
||||
recommendation?: string;
|
||||
actionItems?: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function MetricBars({ metrics }: MetricBarsProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
const getBarColor = (score: number) => {
|
||||
// Use brand orange colors with opacity for gradient effect
|
||||
if (score >= 80) return 'bg-heat-100';
|
||||
if (score >= 60) return 'bg-heat-90';
|
||||
if (score >= 40) return 'bg-heat-40 opacity-80';
|
||||
return 'bg-heat-20';
|
||||
};
|
||||
|
||||
const getBulletColor = (_score: number) => {
|
||||
// Always use heat-100 for all bullets for consistency
|
||||
return 'bg-heat-100';
|
||||
};
|
||||
|
||||
const toggleExpanded = (label: string) => {
|
||||
const newExpanded = new Set(expandedItems);
|
||||
if (newExpanded.has(label)) {
|
||||
newExpanded.delete(label);
|
||||
} else {
|
||||
newExpanded.add(label);
|
||||
}
|
||||
setExpandedItems(newExpanded);
|
||||
};
|
||||
|
||||
// Sort metrics by score descending
|
||||
const sortedMetrics = [...metrics].sort((a, b) => b.score - a.score);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-[800px] mx-auto">
|
||||
{sortedMetrics.map((metric, index) => {
|
||||
const isExpanded = expandedItems.has(metric.label);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={metric.label}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05, duration: 0.3 }}
|
||||
className="space-y-0"
|
||||
>
|
||||
<div
|
||||
className={`grid grid-cols-12 gap-4 items-center p-8 -m-8 rounded-8 cursor-pointer transition-all hover:bg-black-alpha-2 ${
|
||||
isExpanded ? 'bg-black-alpha-4' : ''
|
||||
}`}
|
||||
onClick={() => toggleExpanded(metric.label)}
|
||||
>
|
||||
{/* Bullet and Label - fixed width */}
|
||||
<div className="col-span-4 flex items-center gap-8">
|
||||
<div className={`w-6 h-6 rounded-full ${getBulletColor(metric.score)}`} />
|
||||
<span className="text-label-medium text-accent-black truncate">{metric.label}</span>
|
||||
<motion.div
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="ml-auto"
|
||||
>
|
||||
<ChevronDown className="w-16 h-16 text-black-alpha-32" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Bar container - flexible width */}
|
||||
<div className="col-span-7 relative">
|
||||
<div className="relative h-8 bg-black-alpha-8 rounded-full overflow-hidden">
|
||||
{/* Animated bar */}
|
||||
<motion.div
|
||||
className={`absolute inset-y-0 left-0 ${getBarColor(metric.score)} rounded-full`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.max(metric.score, 2)}%` }}
|
||||
transition={{
|
||||
delay: 0.2 + index * 0.05,
|
||||
duration: 0.8,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
>
|
||||
{/* Subtle inner glow */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-transparent to-white opacity-10 rounded-full" />
|
||||
</motion.div>
|
||||
|
||||
{/* Score indicator lines at key thresholds */}
|
||||
{[40, 60, 80].map(threshold => (
|
||||
<div
|
||||
key={threshold}
|
||||
className="absolute top-0 bottom-0 w-px bg-black-alpha-8 opacity-30"
|
||||
style={{ left: `${threshold}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score value - fixed width */}
|
||||
<div className="col-span-1 text-right">
|
||||
<span className="text-label-medium font-medium text-heat-100">
|
||||
{metric.score}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && metric.details && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="pl-54 pr-12 py-12 space-y-8">
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-48 mb-4">Status</div>
|
||||
<div className="text-body-small text-accent-black">{metric.details}</div>
|
||||
</div>
|
||||
{metric.recommendation && (
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-48 mb-4">Recommendation</div>
|
||||
<div className="text-body-small text-black-alpha-64">{metric.recommendation}</div>
|
||||
</div>
|
||||
)}
|
||||
{metric.actionItems && metric.actionItems.length > 0 && (
|
||||
<div>
|
||||
<div className="text-label-small text-black-alpha-48 mb-4">Action Items</div>
|
||||
<ul className="space-y-4">
|
||||
{metric.actionItems.map((item: string, i: number) => (
|
||||
<li key={i} className="flex items-start gap-6 text-body-small text-black-alpha-64">
|
||||
<span className="text-heat-100 mt-1">•</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Summary stats */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-20 pt-12 border-t border-black-alpha-8"
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-16 text-center">
|
||||
<div>
|
||||
<div className="text-title-h3 text-heat-150">
|
||||
{metrics.filter(m => m.status === 'pass').length}
|
||||
</div>
|
||||
<div className="text-label-small text-black-alpha-48">Passing</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-title-h3 text-heat-100">
|
||||
{metrics.filter(m => m.status === 'warning').length}
|
||||
</div>
|
||||
<div className="text-label-small text-black-alpha-48">Warning</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-title-h3 text-heat-50">
|
||||
{metrics.filter(m => m.status === 'fail').length}
|
||||
</div>
|
||||
<div className="text-label-small text-black-alpha-48">Failing</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface RadarChartProps {
|
||||
data: {
|
||||
label: string;
|
||||
score: number;
|
||||
maxScore?: number;
|
||||
}[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function RadarChart({ data, size = 300 }: RadarChartProps) {
|
||||
const [isAnimated, setIsAnimated] = useState(false);
|
||||
const center = size / 2;
|
||||
const radius = (size / 2) - 60; // Increased padding for labels
|
||||
const angleStep = (Math.PI * 2) / data.length;
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnimated(true);
|
||||
}, []);
|
||||
|
||||
// Calculate points for the polygon
|
||||
const getPoint = (value: number, index: number) => {
|
||||
const angle = index * angleStep - Math.PI / 2;
|
||||
const r = (value / 100) * radius;
|
||||
return {
|
||||
x: center + r * Math.cos(angle),
|
||||
y: center + r * Math.sin(angle)
|
||||
};
|
||||
};
|
||||
|
||||
// Create polygon points string
|
||||
const polygonPoints = data
|
||||
.map((item, i) => {
|
||||
const point = getPoint(isAnimated ? item.score : 0, i);
|
||||
return `${point.x},${point.y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
// Grid levels
|
||||
const gridLevels = [20, 40, 60, 80, 100];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg width={size} height={size} className="overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="radar-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FF4A00" stopOpacity="0.8" />
|
||||
<stop offset="100%" stopColor="#FF8533" stopOpacity="0.3" />
|
||||
</linearGradient>
|
||||
<filter id="radar-glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Grid circles */}
|
||||
{gridLevels.map((level) => (
|
||||
<circle
|
||||
key={level}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={(level / 100) * radius}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.05)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Axis lines */}
|
||||
{data.map((_, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const x2 = center + radius * Math.cos(angle);
|
||||
const y2 = center + radius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={center}
|
||||
y1={center}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="rgba(0,0,0,0.05)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data polygon */}
|
||||
<motion.polygon
|
||||
points={polygonPoints}
|
||||
fill="url(#radar-gradient)"
|
||||
stroke="#FF4A00"
|
||||
strokeWidth="2"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
filter="url(#radar-glow)"
|
||||
/>
|
||||
|
||||
{/* Data points */}
|
||||
{data.map((item, i) => {
|
||||
const point = getPoint(item.score, i);
|
||||
return (
|
||||
<motion.circle
|
||||
key={i}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4"
|
||||
fill="#FF4A00"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: isAnimated ? 1 : 0 }}
|
||||
transition={{ delay: 0.8 + i * 0.1, duration: 0.3 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Labels */}
|
||||
{data.map((item, i) => {
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
const labelRadius = radius + 40; // Increased label distance
|
||||
const x = center + labelRadius * Math.cos(angle);
|
||||
const y = center + labelRadius * Math.sin(angle);
|
||||
|
||||
// Better text anchor logic based on quadrant
|
||||
let textAnchor = "middle";
|
||||
let dy = 0;
|
||||
|
||||
// Left side
|
||||
if (x < center - 20) {
|
||||
textAnchor = "end";
|
||||
}
|
||||
// Right side
|
||||
else if (x > center + 20) {
|
||||
textAnchor = "start";
|
||||
}
|
||||
|
||||
// Top
|
||||
if (y < center - 20) {
|
||||
dy = -5;
|
||||
}
|
||||
// Bottom
|
||||
else if (y > center + 20) {
|
||||
dy = 5;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.g key={i}>
|
||||
{/* Background for better readability */}
|
||||
<motion.rect
|
||||
x={x - (textAnchor === "middle" ? 30 : textAnchor === "end" ? 60 : 0)}
|
||||
y={y - 10}
|
||||
width={60}
|
||||
height={20}
|
||||
fill="white"
|
||||
fillOpacity={0.9}
|
||||
rx={4}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.9 + i * 0.05 }}
|
||||
/>
|
||||
<motion.text
|
||||
x={x}
|
||||
y={y + dy}
|
||||
textAnchor={textAnchor as any}
|
||||
dominantBaseline="middle"
|
||||
className="text-xs fill-black-alpha-80 font-medium"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 + i * 0.05 }}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{item.label}
|
||||
</motion.text>
|
||||
{/* Score value */}
|
||||
<motion.text
|
||||
x={x}
|
||||
y={y + dy + 12}
|
||||
textAnchor={textAnchor as any}
|
||||
dominantBaseline="middle"
|
||||
className="text-[10px] fill-heat-100 font-bold"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.1 + i * 0.05 }}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{item.score}%
|
||||
</motion.text>
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-16 flex justify-center">
|
||||
<div className="inline-flex flex-row gap-16 text-xs text-black-alpha-48 bg-white px-16 py-8 rounded-6 shadow-sm">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-12 h-12 rounded-full bg-heat-200" />
|
||||
<span className="whitespace-nowrap">80-100%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-12 h-12 rounded-full bg-heat-100" />
|
||||
<span className="whitespace-nowrap">60-79%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="w-12 h-12 rounded-full bg-heat-50" />
|
||||
<span className="whitespace-nowrap"><60%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ScoreChartProps {
|
||||
score: number;
|
||||
enhanced?: boolean;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function ScoreChart({ score, enhanced = false, size = 200 }: ScoreChartProps) {
|
||||
const [animatedScore, setAnimatedScore] = useState(0);
|
||||
const radius = size / 2 - 20;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setAnimatedScore(score);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [score]);
|
||||
|
||||
// Calculate stroke dash offset for the progress
|
||||
const strokeDashoffset = circumference - (animatedScore / 100) * circumference;
|
||||
|
||||
// Determine color based on score
|
||||
const getColor = () => {
|
||||
if (score >= 80) return "#FF4A00"; // heat-200 - Excellent
|
||||
if (score >= 60) return "#FF6500"; // heat-150 - Good
|
||||
if (score >= 40) return "#FF8533"; // heat-100 - Warning
|
||||
return "#FFA566"; // heat-50 - Poor
|
||||
};
|
||||
|
||||
const getGradientId = enhanced ? "enhanced-gradient" : "normal-gradient";
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<defs>
|
||||
<linearGradient id={getGradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={getColor()} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={enhanced ? "#FF8533" : getColor()} stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(0,0,0,0.05)"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
|
||||
{/* Progress circle */}
|
||||
<motion.circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={`url(#${getGradientId})`}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
filter="url(#glow)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<motion.div
|
||||
className="text-4xl font-bold text-heat-150"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.5 }}
|
||||
>
|
||||
{animatedScore}%
|
||||
</motion.div>
|
||||
{enhanced && (
|
||||
<motion.div
|
||||
className="text-xs text-heat-100 mt-1"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
AI Enhanced
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user