CrawlerX-frontend/components/pages/components-pages-faq-with-tabs.tsx

503 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useState } from 'react';
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts';
import IconDesktop from '@/components/icon/icon-desktop';
import IconRefresh from '@/components/icon/icon-refresh';
import IconInfoCircle from '@/components/icon/icon-info-circle';
interface ReportMobileDesktopProps {
reports: {
mobile: { report: any };
desktop: { report: any };
} | null;
loading: boolean;
}
const CustomizedContent = (props: any) => {
const { root, depth, x, y, width, height, index, name, colors, bg, resourceSize } = props;
return (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
style={{
fill: bg || '#3b82f633',
stroke: '#fff',
strokeWidth: 2 / (depth + 1),
strokeOpacity: 1 / (depth + 1),
}}
/>
{width > 50 && height > 30 && (
<text
x={x + width / 2}
y={y + height / 2}
textAnchor="middle"
fill="#fff"
fontSize={12}
fontFamily="inherit"
fontWeight="bold"
>
{name.split('/').pop().split('?')[0].substring(0, 15)}
</text>
)}
</g>
);
};
const ReportMobileDesktop: React.FC<ReportMobileDesktopProps> = ({ reports, loading }) => {
const [activeTab, setActiveTab] = useState<'mobile' | 'desktop'>('mobile');
const getScoreColor = (score: number) => {
if (score >= 90) return '#10b981'; // Green-500
if (score >= 50) return '#f59e0b'; // Amber-500
return '#ef4444'; // Red-500
};
const getScoreLabel = (score: number) => {
if (score >= 90) return 'Exceptional';
if (score >= 50) return 'Needs Improvement';
return 'Critical';
};
const [hoveredMetric, setHoveredMetric] = useState<string | null>(null);
const currentReport = reports?.[activeTab]?.report || null;
// Format data for Recharts Treemap
const formatTreemapData = (data: any[]) => {
if (!data || !Array.isArray(data)) return [];
return data
.filter(node => node.resourceSize > 1000) // Only show relevant scripts > 1KB
.map((node, i) => ({
name: node.name || 'Unknown',
size: node.resourceSize || 0,
bg: i % 2 === 0 ? '#3b82f6' : '#2563eb', // Alternating blues
resourceSize: (node.resourceSize / 1024).toFixed(1) + ' KB'
}))
.sort((a, b) => b.size - a.size)
.slice(0, 15); // Top 15 scripts
};
const treemapData = formatTreemapData(currentReport?.treemapData);
const getMetricColor = (label: string, value: string) => {
const num = parseFloat(value || "0");
if (label === 'FCP') return num <= 1.8 ? '#10b981' : num <= 3.0 ? '#f59e0b' : '#ef4444';
if (label === 'LCP') return num <= 2.5 ? '#10b981' : num <= 4.0 ? '#f59e0b' : '#ef4444';
if (label === 'TBT') return num <= 200 ? '#10b981' : num <= 600 ? '#f59e0b' : '#ef4444';
if (label === 'CLS') return num <= 0.1 ? '#10b981' : num <= 0.25 ? '#f59e0b' : '#ef4444';
if (label === 'SI') return num <= 3.4 ? '#10b981' : num <= 5.8 ? '#f59e0b' : '#ef4444';
return '#e5e7eb';
};
const renderScoreCircleLarge = (score: number, metrics: any) => {
const radius = 100;
const stroke = 12;
const innerRadius = radius - stroke;
const circumference = 2 * Math.PI * innerRadius;
const mainColor = getScoreColor(score);
// Weights: FCP (10%), SI (10%), LCP (25%), TBT (30%), CLS (25%)
const segments = [
{ id: 'FCP', label: 'FCP', weight: 0.10, value: metrics?.firstContentfulPaint },
{ id: 'LCP', label: 'LCP', weight: 0.25, value: metrics?.largestContentfulPaint },
{ id: 'TBT', label: 'TBT', weight: 0.30, value: metrics?.totalBlockingTime },
{ id: 'CLS', label: 'CLS', weight: 0.25, value: metrics?.cumulativeLayoutShift },
{ id: 'SI', label: 'SI', weight: 0.10, value: metrics?.speedIndex },
];
let currentOffset = 0;
const gap = 4; // Degrees of gap between segments
return (
<div className="flex flex-col items-center">
<div className="relative inline-flex items-center justify-center">
{/* Background glow */}
<div className="absolute inset-0 blur-[100px] opacity-30 rounded-full transition-all duration-1000" style={{ backgroundColor: mainColor }}></div>
<svg height={radius * 2} width={radius * 2} className="relative transform -rotate-90">
{segments.map((seg, idx) => {
const segmentLength = (seg.weight * circumference) - (gap * (circumference / 360));
const dashArray = `${segmentLength} ${circumference - segmentLength}`;
const dashOffset = -currentOffset;
const color = getMetricColor(seg.id, seg.value);
// Calculate position for labels
const angle = (currentOffset / circumference) * 360 + (seg.weight * 180);
const labelRadius = radius + 25;
const lx = radius + labelRadius * Math.cos((angle * Math.PI) / 180);
const ly = radius + labelRadius * Math.sin((angle * Math.PI) / 180);
currentOffset += (seg.weight * circumference);
return (
<g
key={seg.id}
onMouseEnter={() => setHoveredMetric(seg.label)}
onMouseLeave={() => setHoveredMetric(null)}
className="cursor-pointer transition-all duration-300"
>
{/* Track */}
<circle
stroke={color}
strokeOpacity={0.15}
fill="transparent"
strokeWidth={stroke}
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
r={innerRadius}
cx={radius}
cy={radius}
/>
{/* Progress */}
<circle
stroke={color}
fill="transparent"
strokeWidth={stroke}
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
strokeLinecap="round"
className={`transition-all duration-500 ${hoveredMetric === seg.label ? 'stroke-[16px]' : 'stroke-[12px]'}`}
r={innerRadius}
cx={radius}
cy={radius}
/>
</g>
);
})}
</svg>
{/* Central Score */}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
{hoveredMetric ? (
<div className="animate-in fade-in zoom-in duration-300">
<span className="text-sm font-black text-gray-400 uppercase tracking-[0.2em]">{hoveredMetric}</span>
<div className="text-4xl font-black tabular-nums" style={{
color: getMetricColor(hoveredMetric === 'FCP' ? 'FCP' : hoveredMetric === 'LCP' ? 'LCP' : hoveredMetric === 'TBT' ? 'TBT' : hoveredMetric === 'CLS' ? 'CLS' : 'SI',
segments.find(s => s.label === hoveredMetric)?.value)
}}>
{segments.find(s => s.label === hoveredMetric)?.value?.split(' ')[0]}
</div>
</div>
) : (
<div className="animate-in fade-in zoom-in duration-500">
<span className="text-7xl font-black tracking-tighter" style={{ color: mainColor }}>
{score}
</span>
</div>
)}
</div>
{/* Metric Labels (SI, FCP, LCP...) */}
<div className="absolute inset-0 pointer-events-none">
{segments.map((seg, idx) => {
// Manual angle positioning for exact match to image
const angles = { SI: -140, FCP: -50, LCP: 20, TBT: 90, CLS: 180 };
const angle = angles[seg.id as keyof typeof angles];
const labelRadius = radius + 35;
const x = radius + labelRadius * Math.cos((angle * Math.PI) / 180);
const y = radius + labelRadius * Math.sin((angle * Math.PI) / 180);
return (
<div
key={seg.id}
className={`absolute transition-all duration-300 font-bold text-[10px] tracking-widest ${hoveredMetric === seg.label ? 'text-black dark:text-white scale-125' : 'text-gray-400'}`}
style={{
left: `${(x / (radius * 2)) * 100}%`,
top: `${(y / (radius * 2)) * 100}%`,
transform: 'translate(-50%, -50%)'
}}
>
{seg.label}
</div>
);
})}
</div>
</div>
<div className="mt-8 text-center space-y-4">
<h2 className="text-4xl font-black text-gray-800 dark:text-white uppercase tracking-tighter">Performance</h2>
<p className="text-gray-500 text-sm font-medium max-w-xs leading-tight">
Values are estimated and may vary. The <span className="text-primary cursor-help underline decoration-dotted">performance score</span> is calculated directly from these metrics.
</p>
<div className="flex items-center justify-center gap-6 pt-4 text-[10px] font-black uppercase tracking-widest text-gray-400">
<div className="flex items-center gap-2"><div className="w-3 h-3 bg-[#ef4444] rotate-45"></div> 0-49</div>
<div className="flex items-center gap-2"><div className="w-3 h-3 bg-[#f59e0b]"></div> 50-89</div>
<div className="flex items-center gap-2"><div className="w-10 h-3 bg-[#10b981] rounded-full"></div> 90-100</div>
</div>
</div>
</div>
);
};
const renderMiniScore = (score: number, title: string) => {
const color = getScoreColor(score);
return (
<div className="flex flex-col items-center gap-3 panel bg-white/50 dark:bg-black/50 backdrop-blur-md border !border-gray-100 dark:!border-gray-800 p-4 transition-transform hover:scale-105">
<div className="w-12 h-12 rounded-full flex items-center justify-center font-black border-4 text-lg" style={{ borderColor: `${color}40`, color, backgroundColor: `${color}10` }}>
{score}
</div>
<span className="text-[10px] font-black uppercase text-gray-500 tracking-widest">{title}</span>
</div>
);
};
const renderMetricPSI = (label: string, value: string) => {
let statusColor = "#10b981";
const num = parseFloat(value || "0");
if (label.includes('Paint') || label.includes('Interactive')) {
const limit = label.includes('Largest') ? 2.5 : 1.5;
if (num > limit) statusColor = "#f59e0b";
if (num > limit * 2) statusColor = "#ef4444";
}
return (
<div className="relative p-6 rounded-2xl border border-gray-100 dark:border-gray-800 bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-black">
<div className="flex items-center gap-3 mb-3">
<div className="w-2 h-6 rounded-full" style={{ backgroundColor: statusColor }}></div>
<span className="text-gray-500 dark:text-gray-400 font-bold uppercase text-[10px] tracking-widest">{label}</span>
</div>
<div className="text-3xl font-black text-black dark:text-white px-5 tabular-nums">{value || '---'}</div>
</div>
);
};
if (loading) {
return (
<div className="mt-20 flex flex-col items-center justify-center py-20 bg-white dark:bg-black rounded-[40px] shadow-2xl border border-gray-100 dark:border-gray-800">
<div className="relative w-32 h-32">
<div className="absolute inset-0 border-8 border-primary/10 rounded-full"></div>
<div className="absolute inset-0 border-8 border-transparent border-t-primary rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-4xl">🚀</span>
</div>
</div>
<h3 className="mt-10 text-3xl font-black text-gray-800 dark:text-white leading-tight">Quantifying speed...</h3>
<p className="mt-3 text-gray-400 font-medium max-w-sm text-center">Simulated device runs are operating in parallel to provide instant feedback.</p>
<div className="mt-8 flex gap-2">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:-0.3s]"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:-0.15s]"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
</div>
</div>
);
}
if (!reports) return null;
return (
<div className="mt-16 space-y-16 animate-fade-in">
{/* Environment Control */}
<div className="flex flex-col items-center">
<div className="bg-gray-100 dark:bg-gray-800/50 backdrop-blur-xl p-1.5 rounded-[2rem] flex gap-2 shadow-inner border border-white/10">
<button
onClick={() => setActiveTab('mobile')}
className={`flex items-center gap-3 px-10 py-4 rounded-[1.8rem] font-black transition-all duration-500 ${activeTab === 'mobile' ? 'bg-primary text-white shadow-xl scale-105' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
<span className="text-xl">📱</span> MOBILE
</button>
<button
onClick={() => setActiveTab('desktop')}
className={`flex items-center gap-3 px-10 py-4 rounded-[1.8rem] font-black transition-all duration-500 ${activeTab === 'desktop' ? 'bg-primary text-white shadow-xl scale-105' : 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}`}
>
<span className="text-xl">💻</span> DESKTOP
</button>
</div>
</div>
{/* Core Section: Large Score and Summary */}
<div className="panel bg-[#f8fafc] dark:bg-[#0b1120] border-none shadow-none rounded-[40px] p-10 lg:p-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-20 items-center">
<div className="flex flex-col items-center lg:items-start space-y-12">
{renderScoreCircleLarge(currentReport?.scores?.performance, currentReport?.metrics)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
{renderMiniScore(currentReport?.scores?.accessibility, "Accessibility")}
{renderMiniScore(currentReport?.scores?.bestPractices, "Best Practices")}
{renderMiniScore(currentReport?.scores?.seo, "SEO")}
{currentReport?.scores?.pwa !== null && renderMiniScore(currentReport?.scores?.pwa, "PWA")}
</div>
</div>
<div className="space-y-8">
<div className="relative group overflow-hidden rounded-[32px] border-4 border-white dark:border-gray-800 shadow-2xl bg-white dark:bg-black p-4 transition-transform hover:-rotate-1">
<img
src={currentReport?.screenshot}
alt="Site Screenshot"
className="w-full h-auto object-contain rounded-2xl grayscale-[20%] group-hover:grayscale-0 transition-all duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
</div>
<div className="flex justify-between items-center px-4">
<div className="text-[10px] font-black text-gray-400 uppercase tracking-[0.3em]">Device: {activeTab.toUpperCase()}</div>
<div className="text-[10px] font-black text-primary uppercase tracking-[0.3em]">Status: AUDIT COMPLETE</div>
</div>
</div>
</div>
</div>
{/* Metrics Section */}
<div className="space-y-8">
<div className="flex items-center gap-4">
<h3 className="text-2xl font-black text-gray-800 dark:text-white uppercase tracking-tighter">Vital Metrics</h3>
<div className="h-px flex-1 bg-gradient-to-r from-gray-200 dark:from-gray-800 to-transparent"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{renderMetricPSI("First Contentful Paint", currentReport?.metrics?.firstContentfulPaint)}
{renderMetricPSI("Speed Index", currentReport?.metrics?.speedIndex)}
{renderMetricPSI("Largest Contentful Paint", currentReport?.metrics?.largestContentfulPaint)}
{renderMetricPSI("Time to Interactive", currentReport?.metrics?.timeToInteractive)}
{renderMetricPSI("Total Blocking Time", currentReport?.metrics?.totalBlockingTime)}
{renderMetricPSI("Cumulative Layout Shift", currentReport?.metrics?.cumulativeLayoutShift)}
</div>
</div>
{/* Timeline Filmstrip */}
{currentReport?.thumbnails?.length > 0 && (
<div className="space-y-6">
<div className="flex items-center gap-2 px-2 text-[10px] font-black text-gray-400 tracking-widest uppercase">
<span>Filmstrip</span>
<div className="w-1 h-1 bg-gray-400 rounded-full"></div>
<span>Visual Loading sequence</span>
</div>
<div className="flex overflow-x-auto pb-4 gap-2 scrollbar-hide">
{currentReport.thumbnails.map((thumb: any, i: number) => (
<div key={i} className="shrink-0 flex flex-col items-center group">
<div className="p-1 rounded-xl bg-gray-100 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-800 group-hover:scale-105 transition-transform duration-300">
<img src={thumb.data} alt={`Frame ${i}`} className="h-28 w-auto rounded-lg shadow-sm bg-white" />
</div>
<span className="text-[10px] font-bold mt-2 text-gray-500">{(thumb.timing / 1000).toFixed(1)}s</span>
</div>
))}
</div>
</div>
)}
{/* Detailed Audits Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 pt-10 border-t dark:border-gray-800">
{/* Opportunities */}
<div className="space-y-8">
<div className="flex items-center gap-3">
<span className="text-2xl"></span>
<h3 className="text-xl font-black uppercase tracking-tighter text-gray-800 dark:text-white">Performance Opportunities</h3>
</div>
<div className="space-y-4">
{currentReport?.opportunities?.length > 0 ? (
currentReport.opportunities.map((op: any, i: number) => (
<div key={i} className="group p-6 rounded-3xl bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 hover:border-amber-200 dark:hover:border-amber-900/50 transition-all shadow-sm hover:shadow-xl">
<div className="flex justify-between items-start mb-2">
<h4 className="font-black text-blue-600 dark:text-blue-400 pr-4">{op.title}</h4>
<span className="px-3 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 text-[10px] font-black rounded-full whitespace-nowrap">-{op.estimatedSavings}</span>
</div>
<p className="text-sm text-gray-500 leading-relaxed font-medium">{op.description.split('[')[0]}</p>
</div>
))
) : (
<div className="p-10 text-center rounded-3xl bg-green-50 dark:bg-green-950/20 text-green-600 font-bold border-2 border-dashed border-green-200 dark:border-green-900/30">
No performance bottlenecks identified!
</div>
)}
</div>
</div>
{/* Diagnostics */}
<div className="space-y-8">
<div className="flex items-center gap-3">
<span className="text-2xl">🛡</span>
<h3 className="text-xl font-black uppercase tracking-tighter text-gray-800 dark:text-white">Security & Best Practices</h3>
</div>
<div className="grid grid-cols-1 gap-4">
{Object.entries(currentReport?.diagnostics || {}).map(([key, pass]: any, i: number) => (
<div key={i} className="flex items-center justify-between p-5 rounded-2xl bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 transition-all hover:translate-x-1">
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${pass ? 'bg-green-100 dark:bg-green-950 text-green-600' : 'bg-red-100 dark:bg-red-950 text-red-600'}`}>
{pass ? "✓" : "!"}
</div>
<span className="font-bold text-gray-700 dark:text-gray-200 capitalize tracking-tight">
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (str: string) => str.toUpperCase())}
</span>
</div>
<span className={`text-[10px] font-black px-3 py-1 rounded-full uppercase tracking-tighter ${pass ? 'bg-green-50 dark:bg-green-950 text-green-600' : 'bg-red-50 dark:bg-red-950 text-red-600'}`}>
{pass ? "Secure" : "Warning"}
</span>
</div>
))}
</div>
</div>
</div>
{/* Passed Audits Recap */}
<div className="space-y-6">
<div className="flex items-center gap-2 px-2 text-[10px] font-black text-green-500 tracking-widest uppercase">
<span>Passed Checks</span>
<div className="w-1 h-1 bg-green-500 rounded-full"></div>
<span>All successful audits</span>
</div>
<div className="flex flex-wrap gap-2">
{currentReport?.passedAudits?.map((title: string, i: number) => (
<div key={i} className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-100 dark:border-gray-800 rounded-full group hover:border-green-200 transition-colors">
<span className="text-green-500 text-xs"></span>
<span className="text-[11px] font-bold text-gray-500 group-hover:text-green-600 transition-colors">{title}</span>
</div>
))}
</div>
</div>
{/* Treemap Inline Visualization */}
{treemapData && treemapData.length > 0 && (
<div className="space-y-8 pt-10 border-t dark:border-gray-800">
<div className="flex flex-col items-center text-center space-y-2">
<div className="flex items-center gap-2 px-2 text-[10px] font-black text-primary tracking-widest uppercase">
<span>Resource Map</span>
<div className="w-1 h-1 bg-primary rounded-full"></div>
<span>Javascript Distribution</span>
</div>
<h3 className="text-3xl font-black text-gray-800 dark:text-white uppercase tracking-tighter">Script Treemap Analysis</h3>
<p className="text-gray-500 max-w-xl text-sm font-medium">Visual breakdown of your top 15 scripts by resource size. Identifying large third-party scripts is key to performance.</p>
</div>
<div className="panel bg-white dark:bg-black border border-gray-100 dark:border-gray-800 rounded-[40px] p-8 shadow-xl overflow-hidden">
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<Treemap
data={treemapData}
dataKey="size"
aspectRatio={4 / 3}
stroke="#fff"
fill="#3b82f6"
content={<CustomizedContent />}
>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-black/90 backdrop-blur-xl border border-white/20 p-4 rounded-2xl shadow-2xl text-white">
<p className="text-[10px] font-black uppercase text-primary tracking-widest mb-1">Resource Info</p>
<p className="font-bold text-sm mb-2 max-w-[200px] truncate">{data.name}</p>
<div className="flex items-center gap-4">
<div>
<p className="text-[10px] text-gray-400 uppercase font-bold">Total Size</p>
<p className="text-lg font-black text-green-400">{data.resourceSize}</p>
</div>
</div>
</div>
);
}
return null;
}}
/>
</Treemap>
</ResponsiveContainer>
</div>
</div>
</div>
)}
</div>
);
};
export default ReportMobileDesktop;