feat: initialize monorepo with full dev team best practices
- Unified monorepo with backend (Express), frontend (Next.js), and devops - Backend: ESLint, Prettier, Jest tests (3 passing), health endpoint, .env.example - Frontend: Fixed build errors, fixed all lint errors (0 remaining), tests passing - DevOps: Docker Compose with PostgreSQL, backend, frontend + healthchecks - CI/CD: 3 GitHub Actions workflows (backend, frontend, docker integration) - DX: Husky pre-commit hooks with smart change detection - Docs: Root README with architecture, CONTRIBUTING.md, PR template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
|
||||
import { Badge } from "@/components/ui/layout/Badge";
|
||||
import {
|
||||
Search,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Target,
|
||||
FileText,
|
||||
Link,
|
||||
Image,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
|
||||
|
||||
interface SEOMetric {
|
||||
id: string;
|
||||
website_name: string;
|
||||
website_url: string;
|
||||
seo_score: number;
|
||||
title_tag: boolean;
|
||||
meta_description: boolean;
|
||||
h1_tag: boolean;
|
||||
image_alt_text: number;
|
||||
internal_links: number;
|
||||
external_links: number;
|
||||
page_speed_score: number;
|
||||
mobile_friendly: boolean;
|
||||
ssl_certificate: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SEOSummary {
|
||||
totalPages: number;
|
||||
averageSEOScore: number;
|
||||
goodSEO: number;
|
||||
needsImprovement: number;
|
||||
poor: number;
|
||||
}
|
||||
|
||||
export default function SEOPage() {
|
||||
const { userDetails } = useAuth();
|
||||
const [metrics, setMetrics] = useState<SEOMetric[]>([]);
|
||||
const [summary, setSummary] = useState<SEOSummary>({
|
||||
totalPages: 0,
|
||||
averageSEOScore: 0,
|
||||
goodSEO: 0,
|
||||
needsImprovement: 0,
|
||||
poor: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.organization_id) {
|
||||
loadSEOData();
|
||||
}
|
||||
}, [userDetails, timeRange]);
|
||||
|
||||
const loadSEOData = async () => {
|
||||
if (!userDetails?.organization_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
// Fetch latest SEO data
|
||||
const { data: scanData, error } = await supabase
|
||||
.from("scans")
|
||||
.select(`
|
||||
id,
|
||||
lighthouse_score,
|
||||
created_at,
|
||||
scan_results!inner (
|
||||
category,
|
||||
score,
|
||||
details
|
||||
),
|
||||
pages!inner (
|
||||
id,
|
||||
url,
|
||||
websites!inner (
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
organization_id
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq("pages.websites.organization_id", userDetails.organization_id)
|
||||
.eq("status", "completed")
|
||||
.gte("created_at", startDate.toISOString())
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Process SEO data
|
||||
const pageMetrics = new Map<string, SEOMetric>();
|
||||
|
||||
scanData?.forEach((scan: any) => {
|
||||
const page = scan.pages;
|
||||
const website = page.websites;
|
||||
const seoResult = scan.scan_results?.find((r: any) => r.category === "seo");
|
||||
const performanceResult = scan.scan_results?.find((r: any) => r.category === "performance");
|
||||
|
||||
if (!pageMetrics.has(page.id) && seoResult) {
|
||||
const details = seoResult.details || {};
|
||||
|
||||
pageMetrics.set(page.id, {
|
||||
id: scan.id,
|
||||
website_name: website.name,
|
||||
website_url: page.url || website.base_url,
|
||||
seo_score: seoResult.score || 0,
|
||||
title_tag: details.has_title_tag || false,
|
||||
meta_description: details.has_meta_description || false,
|
||||
h1_tag: details.has_h1_tag || false,
|
||||
image_alt_text: details.images_with_alt || 0,
|
||||
internal_links: details.internal_links || 0,
|
||||
external_links: details.external_links || 0,
|
||||
page_speed_score: performanceResult?.score || 0,
|
||||
mobile_friendly: details.mobile_friendly || false,
|
||||
ssl_certificate: website.base_url?.startsWith("https://") || false,
|
||||
created_at: scan.created_at,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metricsArray = Array.from(pageMetrics.values());
|
||||
setMetrics(metricsArray);
|
||||
|
||||
// Calculate summary
|
||||
if (metricsArray.length > 0) {
|
||||
const avgScore = Math.round(
|
||||
metricsArray.reduce((sum, m) => sum + m.seo_score, 0) / metricsArray.length
|
||||
);
|
||||
const good = metricsArray.filter(m => m.seo_score >= 90).length;
|
||||
const needsImprovement = metricsArray.filter(m => m.seo_score >= 50 && m.seo_score < 90).length;
|
||||
const poor = metricsArray.filter(m => m.seo_score < 50).length;
|
||||
|
||||
setSummary({
|
||||
totalPages: metricsArray.length,
|
||||
averageSEOScore: avgScore,
|
||||
goodSEO: good,
|
||||
needsImprovement,
|
||||
poor,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const errorInfo = extractSupabaseErrorInfo(error);
|
||||
logError("Error loading SEO data", error, {
|
||||
organizationId: userDetails.organization_id,
|
||||
timeRange,
|
||||
function: "loadSEOData",
|
||||
supabaseError: errorInfo
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return "text-green-600 bg-green-100";
|
||||
if (score >= 50) return "text-yellow-600 bg-yellow-100";
|
||||
return "text-red-600 bg-red-100";
|
||||
};
|
||||
|
||||
const getScoreIcon = (score: number) => {
|
||||
if (score >= 90) return <CheckCircle className="w-4 h-4" />;
|
||||
if (score >= 50) return <AlertTriangle className="w-4 h-4" />;
|
||||
return <TrendingDown className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Search className="w-6 h-6" />
|
||||
SEO Analysis
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Monitor and optimize your websites' search engine optimization
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value as "7d" | "30d" | "90d")}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Pages</p>
|
||||
<p className="text-2xl font-bold">{summary.totalPages}</p>
|
||||
</div>
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Average SEO Score</p>
|
||||
<p className="text-2xl font-bold">{summary.averageSEOScore}</p>
|
||||
</div>
|
||||
<Target className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Good SEO</p>
|
||||
<p className="text-2xl font-bold text-green-600">{summary.goodSEO}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Needs Improvement</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{summary.needsImprovement}</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SEO Metrics */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Page SEO Analysis</h2>
|
||||
|
||||
{metrics.length > 0 ? (
|
||||
<div className="grid gap-4">
|
||||
{metrics.map((metric, index) => (
|
||||
<motion.div
|
||||
key={metric.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{metric.website_name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{metric.website_url}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
Last scan: {new Date(metric.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`flex items-center gap-1 ${getScoreColor(metric.seo_score)}`}>
|
||||
{getScoreIcon(metric.seo_score)}
|
||||
{metric.seo_score}/100
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* SEO Checklist */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.title_tag ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Title Tag</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.meta_description ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Meta Description</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.h1_tag ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">H1 Tag</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.ssl_certificate ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">SSL Certificate</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{metric.mobile_friendly ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">Mobile Friendly</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm">{metric.image_alt_text} Images with Alt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links and Performance */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="w-4 h-4 text-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.internal_links}</div>
|
||||
<div className="text-gray-500">Internal Links</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className="w-4 h-4 text-green-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.external_links}</div>
|
||||
<div className="text-gray-500">External Links</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="w-4 h-4 text-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.page_speed_score}</div>
|
||||
<div className="text-gray-500">Speed Score</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-indigo-500" />
|
||||
<div>
|
||||
<div className="font-medium">{metric.seo_score}</div>
|
||||
<div className="text-gray-500">SEO Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Search className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No SEO data available
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Run scans on your websites to see SEO analysis here
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user