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:
Dennis
2026-03-06 00:05:50 +01:00
commit 14a32bdc0d
241 changed files with 71273 additions and 0 deletions
@@ -0,0 +1,494 @@
"use client";
import { useState, useEffect } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Badge } from "@/components/ui/layout/Badge";
import {
Bell,
AlertTriangle,
CheckCircle,
XCircle,
Mail,
Smartphone,
Settings,
Clock,
TrendingDown,
Zap,
Plus,
Loader2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
interface Alert {
id: string;
type: "downtime" | "performance" | "error" | "ssl" | "maintenance";
severity: "low" | "medium" | "high" | "critical";
title: string;
message: string;
website_name: string;
website_url: string;
status: "active" | "resolved" | "acknowledged";
created_at: string;
resolved_at?: string;
acknowledged_at?: string;
}
interface AlertRule {
id: string;
name: string;
type: "downtime" | "performance" | "error_rate";
condition: string;
threshold: number;
enabled: boolean;
notification_methods: string[];
created_at: string;
}
export default function AlertsPage() {
const { userDetails } = useAuth();
const [alerts, setAlerts] = useState<Alert[]>([]);
const [alertRules, setAlertRules] = useState<AlertRule[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"alerts" | "rules">("alerts");
const [processingAlert, setProcessingAlert] = useState<string | null>(null);
useEffect(() => {
if (userDetails?.organization_id) {
loadAlertsData();
}
}, [userDetails]);
const loadAlertsData = async () => {
if (!userDetails?.organization_id) return;
try {
setLoading(true);
// Load alerts
const { data: alertsData, error: alertsError } = await supabase
.from("alerts")
.select(`
id,
type,
severity,
title,
message,
status,
created_at,
resolved_at,
acknowledged_at,
websites!inner (
name,
base_url,
organization_id
)
`)
.eq("websites.organization_id", userDetails.organization_id)
.order("created_at", { ascending: false })
.limit(50);
if (alertsError) throw alertsError;
const formattedAlerts: Alert[] = alertsData?.map((alert: any) => ({
id: alert.id,
type: alert.type,
severity: alert.severity,
title: alert.title,
message: alert.message,
website_name: alert.websites.name,
website_url: alert.websites.base_url,
status: alert.status,
created_at: alert.created_at,
resolved_at: alert.resolved_at,
acknowledged_at: alert.acknowledged_at,
})) || [];
setAlerts(formattedAlerts);
// Load alert rules
const { data: rulesData, error: rulesError } = await supabase
.from("alert_rules")
.select("*")
.eq("organization_id", userDetails.organization_id)
.order("created_at", { ascending: false });
if (rulesError) throw rulesError;
setAlertRules(rulesData || []);
} catch (error) {
const errorInfo = extractSupabaseErrorInfo(error);
logError("Error loading alerts data", error, {
organizationId: userDetails.organization_id,
function: "loadAlertsData",
supabaseError: errorInfo
});
} finally {
setLoading(false);
}
};
const handleAlertAction = async (alertId: string, action: "acknowledge" | "resolve") => {
try {
setProcessingAlert(alertId);
const updateData = action === "acknowledge"
? { status: "acknowledged" as const, acknowledged_at: new Date().toISOString() }
: { status: "resolved" as const, resolved_at: new Date().toISOString() };
const { error } = await supabase
.from("alerts")
.update(updateData)
.eq("id", alertId);
if (error) throw error;
// Update local state
setAlerts(prev => prev.map(alert =>
alert.id === alertId
? { ...alert, ...updateData }
: alert
));
} catch (error) {
console.error(`Error ${action}ing alert:`, error);
} finally {
setProcessingAlert(null);
}
};
const toggleAlertRule = async (ruleId: string, enabled: boolean) => {
try {
const { error } = await supabase
.from("alert_rules")
.update({ enabled: !enabled })
.eq("id", ruleId);
if (error) throw error;
setAlertRules(prev => prev.map(rule =>
rule.id === ruleId
? { ...rule, enabled: !enabled }
: rule
));
} catch (error) {
console.error("Error toggling alert rule:", error);
}
};
const getAlertIcon = (type: string, severity: string) => {
const iconClass = severity === "critical"
? "text-red-500"
: severity === "high"
? "text-orange-500"
: severity === "medium"
? "text-yellow-500"
: "text-blue-500";
switch (type) {
case "downtime":
return <XCircle className={`w-4 h-4 ${iconClass}`} />;
case "performance":
return <TrendingDown className={`w-4 h-4 ${iconClass}`} />;
case "error":
return <AlertTriangle className={`w-4 h-4 ${iconClass}`} />;
case "ssl":
return <Settings className={`w-4 h-4 ${iconClass}`} />;
default:
return <Clock className={`w-4 h-4 ${iconClass}`} />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case "critical":
return "bg-red-100 text-red-800";
case "high":
return "bg-orange-100 text-orange-800";
case "medium":
return "bg-yellow-100 text-yellow-800";
default:
return "bg-blue-100 text-blue-800";
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "resolved":
return "bg-green-100 text-green-800";
case "acknowledged":
return "bg-blue-100 text-blue-800";
default:
return "bg-red-100 text-red-800";
}
};
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>
);
}
const activeAlerts = alerts.filter(a => a.status === "active").length;
const acknowledgedAlerts = alerts.filter(a => a.status === "acknowledged").length;
const resolvedAlerts = alerts.filter(a => a.status === "resolved").length;
const criticalAlerts = alerts.filter(a => a.severity === "critical" && a.status === "active").length;
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">
<Bell className="w-6 h-6" />
Alerts & Notifications
</h1>
<p className="text-gray-600 mt-1">
Monitor and manage alerts for your websites
</p>
</div>
<div className="flex gap-2">
<Button
variant={activeTab === "alerts" ? "default" : "outline"}
onClick={() => setActiveTab("alerts")}
className="flex items-center gap-2"
>
<Bell className="w-4 h-4" />
Alerts
</Button>
<Button
variant={activeTab === "rules" ? "default" : "outline"}
onClick={() => setActiveTab("rules")}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Rules
</Button>
</div>
</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">Active Alerts</p>
<p className="text-2xl font-bold text-red-600">{activeAlerts}</p>
</div>
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Critical</p>
<p className="text-2xl font-bold text-orange-600">{criticalAlerts}</p>
</div>
<XCircle className="w-8 h-8 text-orange-600" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Acknowledged</p>
<p className="text-2xl font-bold text-blue-600">{acknowledgedAlerts}</p>
</div>
<Clock 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">Resolved</p>
<p className="text-2xl font-bold text-green-600">{resolvedAlerts}</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</CardContent>
</Card>
</div>
{/* Content */}
{activeTab === "alerts" ? (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Alerts</h2>
{alerts.length > 0 ? (
<div className="space-y-4">
{alerts.map((alert) => (
<Card key={alert.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{getAlertIcon(alert.type, alert.severity)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900">{alert.title}</h3>
<Badge className={getSeverityColor(alert.severity)}>
{alert.severity.toUpperCase()}
</Badge>
<Badge className={getStatusColor(alert.status)}>
{alert.status.toUpperCase()}
</Badge>
</div>
<p className="text-sm text-gray-600 mb-2">{alert.message}</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>{alert.website_name}</span>
<span></span>
<span>{new Date(alert.created_at).toLocaleString()}</span>
{alert.resolved_at && (
<>
<span></span>
<span>Resolved: {new Date(alert.resolved_at).toLocaleString()}</span>
</>
)}
</div>
</div>
</div>
{alert.status === "active" && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleAlertAction(alert.id, "acknowledge")}
disabled={processingAlert === alert.id}
className="flex items-center gap-1"
>
{processingAlert === alert.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Clock className="w-3 h-3" />
)}
Acknowledge
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleAlertAction(alert.id, "resolve")}
disabled={processingAlert === alert.id}
className="flex items-center gap-1 text-green-600 hover:text-green-700"
>
{processingAlert === alert.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCircle className="w-3 h-3" />
)}
Resolve
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Bell className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No alerts found
</h3>
<p className="text-gray-600">
When issues are detected with your websites, alerts will appear here
</p>
</CardContent>
</Card>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Alert Rules</h2>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Create Rule
</Button>
</div>
{alertRules.length > 0 ? (
<div className="space-y-4">
{alertRules.map((rule) => (
<Card key={rule.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">{rule.name}</h3>
<p className="text-sm text-gray-600">
{rule.type} {rule.condition} {rule.threshold}
</p>
<div className="flex items-center gap-2 mt-2">
{rule.notification_methods.includes("email") && (
<Badge variant="outline" className="flex items-center gap-1">
<Mail className="w-3 h-3" />
Email
</Badge>
)}
{rule.notification_methods.includes("sms") && (
<Badge variant="outline" className="flex items-center gap-1">
<Smartphone className="w-3 h-3" />
SMS
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-4">
<Badge className={rule.enabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}>
{rule.enabled ? "Enabled" : "Disabled"}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => toggleAlertRule(rule.id, rule.enabled)}
>
{rule.enabled ? "Disable" : "Enable"}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Settings className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No alert rules configured
</h3>
<p className="text-gray-600 mb-6">
Create alert rules to get notified about website issues
</p>
<Button className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Create Your First Rule
</Button>
</CardContent>
</Card>
)}
</div>
)}
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Card, CardContent } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { useAuth } from "@/contexts/AuthContext";
export default function DiagnosticsPage() {
const [results, setResults] = useState<any>({});
const [isRunning, setIsRunning] = useState(false);
const { user, userDetails } = useAuth();
const runDiagnostics = async () => {
setIsRunning(true);
const diagnosticResults: any = {
timestamp: new Date().toISOString(),
auth: { user, userDetails },
};
try {
// Test general permissions
const { data: authTest, error: authError } =
await supabase.auth.getUser();
diagnosticResults.authTest = { data: authTest, error: authError };
// Test websites table access - select
const { data: selectTest, error: selectError } = await supabase
.from("websites")
.select("*")
.limit(5);
diagnosticResults.selectTest = { data: selectTest, error: selectError };
// Test organizations table access
const { data: orgTest, error: orgError } = await supabase
.from("organizations")
.select("*")
.limit(5);
diagnosticResults.orgTest = { data: orgTest, error: orgError };
// Test insert (with immediate deletion to avoid clutter)
const testName = `Test Website ${new Date().toISOString()}`;
const { data: insertTest, error: insertError } = await supabase
.from("websites")
.insert([
{
name: testName,
base_url: "https://example.com/test",
organization_id: userDetails?.organization_id,
is_active: true,
},
])
.select();
diagnosticResults.insertTest = { data: insertTest, error: insertError };
// If insert succeeded, delete the test website
if (insertTest && insertTest.length > 0) {
const { data: deleteTest, error: deleteError } = await supabase
.from("websites")
.delete()
.eq("id", insertTest[0].id);
diagnosticResults.deleteTest = { data: deleteTest, error: deleteError };
}
setResults(diagnosticResults);
} catch (error) {
diagnosticResults.error = String(error);
setResults(diagnosticResults);
} finally {
setIsRunning(false);
}
};
return (
<div className="container mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Database Diagnostics</h1>
<Card>
<CardContent className="p-6">
<div className="mb-4">
<Button onClick={runDiagnostics} disabled={isRunning}>
{isRunning ? "Running Tests..." : "Run Diagnostics"}
</Button>
</div>
<div className="mt-6">
<h2 className="text-lg font-semibold mb-2">Results:</h2>
<pre className="bg-gray-100 p-4 rounded-md overflow-auto max-h-[600px] text-xs">
{JSON.stringify(results, null, 2)}
</pre>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { Button } from "@/components/ui/forms/Button";
import { AlertCircle } from "lucide-react";
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<DashboardLayout>
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="text-red-500 mb-4">
<AlertCircle className="h-12 w-12" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Something went wrong!
</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,12 @@
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { LoadingSpinner } from "@/components/ui/feedback/LoadingSpinner";
export default function DashboardLoading() {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-[60vh]">
<LoadingSpinner />
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,431 @@
"use client";
import { useState, useEffect } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Badge } from "@/components/ui/layout/Badge";
import {
Activity,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
Play,
Pause,
Settings,
TrendingUp,
TrendingDown,
Zap,
Loader2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
interface MonitoringStatus {
id: string;
website_name: string;
website_url: string;
is_monitoring: boolean;
last_check: string;
status: "up" | "down" | "warning";
response_time: number;
uptime_percentage: number;
incidents_count: number;
created_at: string;
}
interface UptimeMetric {
website_id: string;
timestamp: string;
status: "up" | "down" | "warning";
response_time: number;
error_message?: string;
}
export default function MonitoringPage() {
const { userDetails } = useAuth();
const [websites, setWebsites] = useState<MonitoringStatus[]>([]);
const [recentChecks, setRecentChecks] = useState<UptimeMetric[]>([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState<string | null>(null);
useEffect(() => {
if (userDetails?.organization_id) {
loadMonitoringData();
// Set up real-time updates
const interval = setInterval(loadMonitoringData, 30000); // Update every 30 seconds
return () => clearInterval(interval);
}
}, [userDetails]);
const loadMonitoringData = async () => {
if (!userDetails?.organization_id) return;
try {
setLoading(true);
// Fetch websites with monitoring status
const { data: websitesData, error: websitesError } = await supabase
.from("websites")
.select(`
id,
name,
base_url,
is_active,
created_at,
uptime_checks (
id,
status,
response_time,
checked_at,
error_message
)
`)
.eq("organization_id", userDetails.organization_id)
.eq("is_active", true)
.order("created_at", { ascending: false });
if (websitesError) throw websitesError;
// Process monitoring data
const monitoringData: MonitoringStatus[] = websitesData?.map((website: any) => {
const checks = website.uptime_checks || [];
const recentChecks = checks
.filter((check: any) => {
const checkDate = new Date(check.checked_at);
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return checkDate >= dayAgo;
})
.sort((a: any, b: any) => new Date(b.checked_at).getTime() - new Date(a.checked_at).getTime());
const latestCheck = recentChecks[0];
const upChecks = recentChecks.filter((check: any) => check.status === "up").length;
const totalChecks = recentChecks.length;
const uptimePercentage = totalChecks > 0 ? Math.round((upChecks / totalChecks) * 100) : 0;
const incidents = recentChecks.filter((check: any) => check.status === "down").length;
return {
id: website.id,
website_name: website.name,
website_url: website.base_url,
is_monitoring: true, // Assume monitoring is enabled for active websites
last_check: latestCheck?.checked_at || website.created_at,
status: latestCheck?.status || "warning",
response_time: latestCheck?.response_time || 0,
uptime_percentage: uptimePercentage,
incidents_count: incidents,
created_at: website.created_at,
};
}) || [];
setWebsites(monitoringData);
// Get recent checks for the timeline
const allChecks: UptimeMetric[] = [];
websitesData?.forEach((website: any) => {
const checks = website.uptime_checks || [];
checks.slice(0, 10).forEach((check: any) => {
allChecks.push({
website_id: website.id,
timestamp: check.checked_at,
status: check.status,
response_time: check.response_time,
error_message: check.error_message,
});
});
});
allChecks.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
setRecentChecks(allChecks.slice(0, 20));
} catch (error) {
const errorInfo = extractSupabaseErrorInfo(error);
logError("Error loading monitoring data", error, {
organizationId: userDetails.organization_id,
function: "loadMonitoringData",
supabaseError: errorInfo
});
} finally {
setLoading(false);
}
};
const toggleMonitoring = async (websiteId: string, currentStatus: boolean) => {
try {
setUpdating(websiteId);
// In a real implementation, you would update monitoring settings
// For now, we'll just simulate the action
await new Promise(resolve => setTimeout(resolve, 1000));
// Update local state
setWebsites(prev => prev.map(website =>
website.id === websiteId
? { ...website, is_monitoring: !currentStatus }
: website
));
} catch (error) {
console.error("Error toggling monitoring:", error);
} finally {
setUpdating(null);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "up":
return <CheckCircle className="w-4 h-4 text-green-500" />;
case "down":
return <XCircle className="w-4 h-4 text-red-500" />;
case "warning":
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
default:
return <Clock className="w-4 h-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "up":
return "bg-green-100 text-green-800";
case "down":
return "bg-red-100 text-red-800";
case "warning":
return "bg-yellow-100 text-yellow-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getUptimeColor = (percentage: number) => {
if (percentage >= 99) return "text-green-600";
if (percentage >= 95) return "text-yellow-600";
return "text-red-600";
};
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>
);
}
const totalWebsites = websites.length;
const activeMonitoring = websites.filter(w => w.is_monitoring).length;
const upWebsites = websites.filter(w => w.status === "up").length;
const downWebsites = websites.filter(w => w.status === "down").length;
const avgUptime = websites.length > 0
? Math.round(websites.reduce((sum, w) => sum + w.uptime_percentage, 0) / websites.length)
: 0;
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">
<Activity className="w-6 h-6" />
Website Monitoring
</h1>
<p className="text-gray-600 mt-1">
Real-time uptime monitoring for your websites
</p>
</div>
<Button
onClick={loadMonitoringData}
className="flex items-center gap-2"
>
<Zap className="w-4 h-4" />
Refresh Status
</Button>
</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 Websites</p>
<p className="text-2xl font-bold">{totalWebsites}</p>
</div>
<Activity 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">Monitoring Active</p>
<p className="text-2xl font-bold text-green-600">{activeMonitoring}</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">Websites Up</p>
<p className="text-2xl font-bold text-green-600">{upWebsites}</p>
</div>
<TrendingUp 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">Average Uptime</p>
<p className={`text-2xl font-bold ${getUptimeColor(avgUptime)}`}>
{avgUptime}%
</p>
</div>
<TrendingUp className="w-8 h-8 text-purple-600" />
</div>
</CardContent>
</Card>
</div>
{/* Monitoring Status */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Website Status</h2>
{websites.length > 0 ? (
<div className="grid gap-4">
{websites.map((website) => (
<Card key={website.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{getStatusIcon(website.status)}
<div>
<h3 className="font-semibold text-gray-900">{website.website_name}</h3>
<p className="text-sm text-gray-500">{website.website_url}</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<Badge className={getStatusColor(website.status)}>
{website.status.toUpperCase()}
</Badge>
<p className="text-xs text-gray-500 mt-1">
Last check: {new Date(website.last_check).toLocaleTimeString()}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">
{website.response_time > 0 ? `${website.response_time}ms` : "—"}
</p>
<p className="text-xs text-gray-500">Response time</p>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${getUptimeColor(website.uptime_percentage)}`}>
{website.uptime_percentage}%
</p>
<p className="text-xs text-gray-500">24h uptime</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-red-600">
{website.incidents_count}
</p>
<p className="text-xs text-gray-500">Incidents</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => toggleMonitoring(website.id, website.is_monitoring)}
disabled={updating === website.id}
className="flex items-center gap-1"
>
{updating === website.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : website.is_monitoring ? (
<Pause className="w-3 h-3" />
) : (
<Play className="w-3 h-3" />
)}
{website.is_monitoring ? "Pause" : "Start"}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Activity className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No websites being monitored
</h3>
<p className="text-gray-600">
Add websites and enable monitoring to see uptime status here
</p>
</CardContent>
</Card>
)}
</div>
{/* Recent Activity */}
{recentChecks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{recentChecks.slice(0, 10).map((check, index) => {
const website = websites.find(w => w.id === check.website_id);
return (
<div key={index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3">
{getStatusIcon(check.status)}
<div>
<p className="text-sm font-medium">{website?.website_name || "Unknown"}</p>
<p className="text-xs text-gray-500">
{new Date(check.timestamp).toLocaleString()}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm">{check.response_time}ms</p>
{check.error_message && (
<p className="text-xs text-red-500">{check.error_message}</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,448 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Input } from "@/components/ui/forms/Input";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/forms/Form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/layout/Tabs";
import { TeamManagement } from "@/components/dashboard/TeamManagement";
import {
Building2,
Settings,
Users,
CreditCard,
Shield,
Trash2,
ArrowLeft,
Save,
Loader2,
AlertCircle,
Check,
} from "lucide-react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { motion, AnimatePresence } from "framer-motion";
import { supabase } from "@/lib/supabase";
interface Organization {
id: string;
name: string;
subscription_tier: string;
subscription_status: string;
created_at: string;
}
const organizationFormSchema = z.object({
name: z.string().min(2, "Organization name must be at least 2 characters"),
});
export default function OrganizationSettingsPage() {
const params = useParams();
const router = useRouter();
const { user, userDetails } = useAuth();
const organizationId = params.id as string;
const [organization, setOrganization] = useState<Organization | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [activeTab, setActiveTab] = useState("general");
const form = useForm<z.infer<typeof organizationFormSchema>>({
resolver: zodResolver(organizationFormSchema),
defaultValues: { name: "" },
});
useEffect(() => {
if (organizationId && user) {
loadOrganization();
}
}, [organizationId, user]);
const loadOrganization = async () => {
try {
setLoading(true);
// Check if user has access to this organization
if (userDetails?.organization_id !== organizationId) {
setError("Access denied. You don't have permission to access this organization.");
return;
}
const { data: org, error: orgError } = await supabase
.from("organizations")
.select("*")
.eq("id", organizationId)
.single();
if (orgError) {
throw orgError;
}
setOrganization(org);
form.setValue("name", org.name);
} catch (error) {
console.error("Error loading organization:", error);
setError("Failed to load organization details");
} finally {
setLoading(false);
}
};
const handleUpdateOrganization = async (values: z.infer<typeof organizationFormSchema>) => {
try {
setSaving(true);
setError("");
setSuccess("");
const { error } = await supabase
.from("organizations")
.update({ name: values.name })
.eq("id", organizationId);
if (error) {
throw error;
}
setOrganization(prev => prev ? { ...prev, name: values.name } : null);
setSuccess("Organization updated successfully!");
} catch (error) {
console.error("Error updating organization:", error);
setError("Failed to update organization");
} finally {
setSaving(false);
}
};
const handleDeleteOrganization = async () => {
if (!confirm("Are you sure you want to delete this organization? This action cannot be undone and will remove all associated data.")) {
return;
}
const confirmText = prompt("Type 'DELETE' to confirm:");
if (confirmText !== "DELETE") {
return;
}
try {
const { error } = await supabase
.from("organizations")
.delete()
.eq("id", organizationId);
if (error) {
throw error;
}
router.push("/dashboard/organizations");
} catch (error) {
console.error("Error deleting organization:", error);
setError("Failed to delete organization");
}
};
const getTierColor = (tier: string) => {
switch (tier) {
case "pro": return "text-blue-600 bg-blue-100";
case "enterprise": return "text-purple-600 bg-purple-100";
default: return "text-gray-600 bg-gray-100";
}
};
const canManageOrganization = userDetails?.role === "owner";
if (loading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
</DashboardLayout>
);
}
if (!organization) {
return (
<DashboardLayout>
<div className="text-center py-12">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Organization Not Found
</h3>
<p className="text-gray-600 mb-6">
The organization you&apos;re looking for doesn&apos;t exist or you don&apos;t have access to it.
</p>
<Button onClick={() => router.push("/dashboard/organizations")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Organizations
</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="max-w-4xl mx-auto py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Button
variant="outline"
onClick={() => router.push("/dashboard/organizations")}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<div>
<h1 className="text-3xl font-bold text-gray-900">{organization.name}</h1>
<p className="text-gray-600 mt-1">Organization Settings</p>
</div>
</div>
{/* Success/Error Messages */}
<AnimatePresence>
{success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3 mb-6"
>
<Check className="w-5 h-5 text-green-500" />
<span className="text-green-800">{success}</span>
<Button
variant="outline"
size="sm"
onClick={() => setSuccess("")}
className="ml-auto"
>
Dismiss
</Button>
</motion.div>
)}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3 mb-6"
>
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-800">{error}</span>
<Button
variant="outline"
size="sm"
onClick={() => setError("")}
className="ml-auto"
>
Dismiss
</Button>
</motion.div>
)}
</AnimatePresence>
{/* Settings Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="general" className="flex items-center gap-2">
<Settings className="w-4 h-4" />
General
</TabsTrigger>
<TabsTrigger value="members" className="flex items-center gap-2">
<Users className="w-4 h-4" />
Members
</TabsTrigger>
<TabsTrigger value="billing" className="flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Billing
</TabsTrigger>
<TabsTrigger value="danger" className="flex items-center gap-2">
<Shield className="w-4 h-4" />
Danger Zone
</TabsTrigger>
</TabsList>
{/* General Settings */}
<TabsContent value="general" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-blue-600" />
Organization Details
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleUpdateOrganization)}
className="space-y-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Organization Name"
disabled={!canManageOrganization || saving}
/>
</FormControl>
<FormDescription>
This name will be visible to all team members
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Organization Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<label className="text-sm font-medium text-gray-700">
Subscription Tier
</label>
<div className="mt-1">
<span className={`inline-block px-3 py-1 rounded-full text-sm ${getTierColor(organization.subscription_tier)}`}>
{organization.subscription_tier.charAt(0).toUpperCase() + organization.subscription_tier.slice(1)}
</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-gray-700">
Created
</label>
<div className="mt-1 text-sm text-gray-900">
{new Date(organization.created_at).toLocaleDateString()}
</div>
</div>
</div>
{canManageOrganization && (
<Button type="submit" disabled={saving}>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
)}
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
{/* Team Members */}
<TabsContent value="members">
<TeamManagement organizationId={organizationId} />
</TabsContent>
{/* Billing */}
<TabsContent value="billing">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="w-5 h-5 text-blue-600" />
Billing & Subscription
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8">
<CreditCard className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Billing Management
</h3>
<p className="text-gray-600 mb-6">
Billing and subscription management features will be available soon.
</p>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-blue-900">Current Plan</div>
<div className="text-sm text-blue-700">
{organization.subscription_tier.charAt(0).toUpperCase() + organization.subscription_tier.slice(1)} Plan
</div>
</div>
<Button variant="outline" disabled>
Manage Billing
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Danger Zone */}
<TabsContent value="danger">
<Card className="border-red-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<Shield className="w-5 h-5" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="font-semibold text-red-900 mb-2">
Delete Organization
</h3>
<p className="text-red-700 text-sm mb-4">
Once you delete an organization, there is no going back. Please be certain.
This will permanently delete all associated websites, data, and team member access.
</p>
{canManageOrganization ? (
<Button
variant="destructive"
onClick={handleDeleteOrganization}
className="flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Organization
</Button>
) : (
<p className="text-sm text-red-600">
Only organization owners can delete the organization.
</p>
)}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,431 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Input } from "@/components/ui/forms/Input";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/forms/Form";
import { Badge } from "@/components/ui/layout/Badge";
import {
Shield,
Check,
Building2,
Users,
Star,
ArrowRight,
Loader2,
Zap,
Globe,
BarChart3,
AlertCircle,
} from "lucide-react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { motion, AnimatePresence } from "framer-motion";
const formSchema = z.object({
name: z.string().min(2, "Organization name must be at least 2 characters"),
});
const features = [
{
icon: Globe,
title: "Website Monitoring",
description:
"Monitor unlimited websites with real-time performance tracking",
},
{
icon: BarChart3,
title: "Advanced Analytics",
description:
"Get detailed insights into performance, SEO, and accessibility",
},
{
icon: Users,
title: "Team Collaboration",
description: "Invite team members and manage access permissions",
},
{
icon: Zap,
title: "Automated Alerts",
description: "Receive instant notifications when issues are detected",
},
];
const plans = [
{
name: "Free",
price: "$0",
period: "forever",
description: "Perfect for getting started",
features: [
"Up to 3 websites",
"Basic monitoring",
"Email alerts",
"7-day data retention",
],
current: true,
},
{
name: "Pro",
price: "$29",
period: "per month",
description: "For growing businesses",
features: [
"Up to 25 websites",
"Advanced monitoring",
"Real-time alerts",
"90-day data retention",
"Team collaboration",
],
popular: true,
},
{
name: "Enterprise",
price: "Custom",
period: "pricing",
description: "For large organizations",
features: [
"Unlimited websites",
"Custom integrations",
"Priority support",
"Unlimited data retention",
"SSO & compliance",
],
},
];
export default function NewOrganizationPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [step, setStep] = useState(1);
const router = useRouter();
const { user, createOrganizationForUser } = useAuth();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.user_metadata?.name
? `${user.user_metadata.name}'s Organization`
: "My Organization",
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!user) {
setError("You must be logged in to create an organization");
return;
}
try {
setLoading(true);
setError("");
const orgId = await createOrganizationForUser(user.id, values.name);
if (!orgId) {
throw new Error("Failed to create organization");
}
setSuccess(true);
setStep(3);
// Redirect after a short delay
setTimeout(() => {
router.push("/dashboard");
}, 2000);
} catch (err: unknown) {
console.error("Failed to create organization:", err);
setError(
err instanceof Error ? err.message : "Failed to create organization",
);
} finally {
setLoading(false);
}
};
const handleContinue = () => {
setStep(2);
};
return (
<DashboardLayout>
<div className="max-w-6xl mx-auto py-8">
<AnimatePresence mode="wait">
{/* Step 1: Welcome */}
{step === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="text-center space-y-8"
>
<div>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-6"
>
<Building2 className="w-10 h-10 text-blue-600" />
</motion.div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Welcome to CloudLense
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Set up your organization to start monitoring your websites and
gain valuable insights into performance, SEO, and
accessibility.
</p>
</div>
{/* Features Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{features.map((feature, index) => {
const Icon = feature.icon;
return (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.1 }}
>
<Card className="text-left hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="p-2 bg-blue-100 rounded-lg">
<Icon className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-2">
{feature.title}
</h3>
<p className="text-sm text-gray-600">
{feature.description}
</p>
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
>
<Button
size="lg"
onClick={handleContinue}
className="px-8 flex items-center gap-2"
>
Get Started
<ArrowRight className="w-4 h-4" />
</Button>
</motion.div>
</motion.div>
)}
{/* Step 2: Organization Setup */}
{step === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="max-w-2xl mx-auto"
>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Create Your Organization
</h1>
<p className="text-gray-600">
Set up your organization to start monitoring websites
</p>
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-blue-600" />
Organization Details
</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input {...field} placeholder="My Organization" />
</FormControl>
<FormDescription>
This name will be visible to all team members
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Plan Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Choose Your Plan
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{plans.map((plan) => (
<div
key={plan.name}
className={`relative border rounded-lg p-4 ${
plan.current
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
>
{plan.popular && (
<Badge className="absolute -top-2 left-1/2 -translate-x-1/2">
<Star className="w-3 h-3 mr-1" />
Popular
</Badge>
)}
<div className="text-center">
<h4 className="font-semibold">{plan.name}</h4>
<div className="mt-2">
<span className="text-2xl font-bold">
{plan.price}
</span>
<span className="text-sm text-gray-500">
/{plan.period}
</span>
</div>
<p className="text-sm text-gray-600 mt-2">
{plan.description}
</p>
</div>
<ul className="mt-4 space-y-2">
{plan.features.map((feature) => (
<li
key={feature}
className="flex items-center text-sm"
>
<Check className="w-3 h-3 text-green-500 mr-2" />
{feature}
</li>
))}
</ul>
{plan.current && (
<Badge
variant="blue"
className="w-full justify-center mt-3"
>
Current Plan
</Badge>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 text-center">
You can upgrade or downgrade your plan at any time
</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-800">{error}</span>
</div>
)}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => setStep(1)}
disabled={loading}
>
Back
</Button>
<Button type="submit" disabled={loading}>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Organization"
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
)}
{/* Step 3: Success */}
{step === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="text-center py-12"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"
>
<Check className="w-10 h-10 text-green-600" />
</motion.div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Organization Created Successfully!
</h1>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
Your organization has been set up. You can now start adding
websites and inviting team members to collaborate.
</p>
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
<span className="text-sm text-blue-600">
Redirecting to dashboard...
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,492 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Input } from "@/components/ui/forms/Input";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/forms/Form";
import {
Building2,
Users,
Settings,
Trash2,
Edit,
Plus,
Calendar,
Crown,
Loader2,
AlertCircle,
} from "lucide-react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { motion, AnimatePresence } from "framer-motion";
import { supabase } from "@/lib/supabase";
interface Organization {
id: string;
name: string;
subscription_tier: string;
subscription_status: string;
created_at: string;
member_count: number;
website_count: number;
user_role: string;
}
const editFormSchema = z.object({
name: z.string().min(2, "Organization name must be at least 2 characters"),
});
export default function OrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [editingOrg, setEditingOrg] = useState<Organization | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [error, setError] = useState("");
const router = useRouter();
const { user, userDetails } = useAuth();
const editForm = useForm<z.infer<typeof editFormSchema>>({
resolver: zodResolver(editFormSchema),
defaultValues: { name: "" },
});
useEffect(() => {
if (user) {
loadOrganizations();
}
}, [user]);
const loadOrganizations = async () => {
try {
setLoading(true);
// Get organizations where user is a member
const { data: userOrgs, error: userOrgError } = await supabase
.from("users")
.select(`
organization_id,
role,
organizations (
id,
name,
subscription_tier,
subscription_status,
created_at
)
`)
.eq("id", user?.id);
if (userOrgError) throw userOrgError;
// Get organization stats
const orgIds = userOrgs?.map(u => u.organization_id).filter(Boolean) || [];
const [membersData, websitesData] = await Promise.all([
// Get member counts
supabase
.from("users")
.select("organization_id")
.in("organization_id", orgIds),
// Get website counts
supabase
.from("websites")
.select("organization_id")
.in("organization_id", orgIds)
]);
const memberCounts = membersData.data?.reduce((acc, member) => {
acc[member.organization_id] = (acc[member.organization_id] || 0) + 1;
return acc;
}, {} as Record<string, number>) || {};
const websiteCounts = websitesData.data?.reduce((acc, website) => {
acc[website.organization_id] = (acc[website.organization_id] || 0) + 1;
return acc;
}, {} as Record<string, number>) || {};
const orgsWithStats = (userOrgs as Array<any> | undefined)?.map((userOrg: any) => ({
id: userOrg.organizations?.id || "",
name: userOrg.organizations?.name || "",
subscription_tier: userOrg.organizations?.subscription_tier || "free",
subscription_status: userOrg.organizations?.subscription_status || "active",
created_at: userOrg.organizations?.created_at || "",
member_count: memberCounts[String(userOrg.organization_id)] || 0,
website_count: websiteCounts[String(userOrg.organization_id)] || 0,
user_role: userOrg.role || "member",
})).filter((org: any) => org.id) || [];
setOrganizations(orgsWithStats);
} catch (error) {
console.error("Error loading organizations:", error);
setError("Failed to load organizations");
} finally {
setLoading(false);
}
};
const handleEditOrganization = async (values: z.infer<typeof editFormSchema>) => {
if (!editingOrg) return;
try {
const { error } = await supabase
.from("organizations")
.update({ name: values.name })
.eq("id", editingOrg.id);
if (error) throw error;
// Update local state
setOrganizations(orgs =>
orgs.map(org =>
org.id === editingOrg.id
? { ...org, name: values.name }
: org
)
);
setEditingOrg(null);
editForm.reset();
} catch (error) {
console.error("Error updating organization:", error);
setError("Failed to update organization");
}
};
const handleDeleteOrganization = async (orgId: string) => {
try {
// First, check if user is owner
const org = organizations.find(o => o.id === orgId);
if (org?.user_role !== "owner") {
setError("Only organization owners can delete organizations");
return;
}
// Delete organization (cascade should handle related records)
const { error } = await supabase
.from("organizations")
.delete()
.eq("id", orgId);
if (error) throw error;
// Update local state
setOrganizations(orgs => orgs.filter(org => org.id !== orgId));
setDeleteConfirm(null);
// If this was the user's current organization, they might need to select a new one
if (userDetails?.organization_id === orgId) {
router.push("/dashboard/organizations/new");
}
} catch (error) {
console.error("Error deleting organization:", error);
setError("Failed to delete organization");
}
};
const startEdit = (org: Organization) => {
setEditingOrg(org);
editForm.setValue("name", org.name);
};
const getTierColor = (tier: string) => {
switch (tier) {
case "pro": return "text-blue-600 bg-blue-100";
case "enterprise": return "text-purple-600 bg-purple-100";
default: return "text-gray-600 bg-gray-100";
}
};
if (loading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="max-w-6xl mx-auto py-8">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Organizations</h1>
<p className="text-gray-600 mt-2">
Manage your organizations and team settings
</p>
</div>
<Button
onClick={() => router.push("/dashboard/organizations/new")}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Organization
</Button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3 mb-6">
<AlertCircle className="w-5 h-5 text-red-500" />
<span className="text-red-800">{error}</span>
<Button
variant="outline"
size="sm"
onClick={() => setError("")}
className="ml-auto"
>
Dismiss
</Button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<AnimatePresence>
{organizations.map((org) => (
<motion.div
key={org.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Building2 className="w-5 h-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-lg">{org.name}</CardTitle>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-1 rounded-full ${getTierColor(org.subscription_tier)}`}>
{org.subscription_tier.charAt(0).toUpperCase() + org.subscription_tier.slice(1)}
</span>
{org.user_role === "owner" && (
<Crown className="w-3 h-3 text-yellow-500" />
)}
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{org.member_count}
</div>
<div className="text-xs text-gray-500 flex items-center justify-center gap-1">
<Users className="w-3 h-3" />
Members
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{org.website_count}
</div>
<div className="text-xs text-gray-500 flex items-center justify-center gap-1">
<Building2 className="w-3 h-3" />
Websites
</div>
</div>
</div>
{/* Created Date */}
<div className="text-xs text-gray-500 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Created {new Date(org.created_at).toLocaleDateString()}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => startEdit(org)}
className="flex-1"
disabled={org.user_role !== "owner"}
>
<Edit className="w-3 h-3 mr-1" />
Edit
</Button>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/organizations/${org.id}/settings`)}
className="flex-1"
>
<Settings className="w-3 h-3 mr-1" />
Settings
</Button>
{org.user_role === "owner" && (
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirm(org.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
{organizations.length === 0 && (
<div className="text-center py-12">
<Building2 className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No Organizations Found
</h3>
<p className="text-gray-600 mb-6">
Create your first organization to start monitoring websites
</p>
<Button
onClick={() => router.push("/dashboard/organizations/new")}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Create Organization
</Button>
</div>
)}
{/* Edit Organization Modal */}
<AnimatePresence>
{editingOrg && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-lg max-w-md w-full"
>
<Card className="border-0">
<CardHeader>
<CardTitle>Edit Organization</CardTitle>
</CardHeader>
<CardContent>
<Form {...editForm}>
<form
onSubmit={editForm.handleSubmit(handleEditOrganization)}
className="space-y-4"
>
<FormField
control={editForm.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Organization Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Organization Name" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
setEditingOrg(null);
editForm.reset();
}}
>
Cancel
</Button>
<Button type="submit">
Update
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation Modal */}
<AnimatePresence>
{deleteConfirm && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="bg-white rounded-lg max-w-md w-full"
>
<Card className="border-0">
<CardHeader>
<CardTitle className="text-red-600">Delete Organization</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-6">
Are you sure you want to delete this organization? This action cannot be undone and will remove all associated websites and data.
</p>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => handleDeleteOrganization(deleteConfirm)}
>
Delete Organization
</Button>
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,571 @@
"use client";
import { useState, useEffect } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { useDashboardData } from "@/hooks/useDashboardData";
import { Card, CardContent } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Badge } from "@/components/ui/layout/Badge";
import {
BarChart3,
Globe,
Zap,
Search,
Plus,
TrendingUp,
TrendingDown,
Clock,
Shield,
Activity,
AlertCircle,
CheckCircle,
RefreshCw,
ExternalLink,
ArrowRight,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { supabase } from "@/lib/supabase";
import { scanService } from "@/services/scanService";
import { DatabaseSetupHelper } from "@/components/ui/DatabaseSetupHelper";
import { SupabaseDiagnostic } from "@/components/ui/SupabaseDiagnostic";
interface DashboardStats {
websitesCount: number;
activePages: number;
totalScans: number;
averagePerformance: number;
lastScanTime: string;
recentScans: any[];
websites: any[];
}
export default function DashboardPage() {
const { userDetails, organizationId, shouldShowLoading } = useDashboardData({ requireOrganization: false });
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (organizationId) {
loadDashboardData();
} else if (userDetails) {
// User exists but no organization, show empty dashboard
loadDashboardData();
}
}, [organizationId, userDetails]);
const loadDashboardData = async () => {
if (!userDetails?.organization_id) {
console.log("No organization_id yet, showing empty dashboard");
setStats({
websitesCount: 0,
activePages: 0,
totalScans: 0,
averagePerformance: 0,
lastScanTime: "Never",
recentScans: [],
websites: [],
});
setLoading(false);
return;
}
try {
setLoading(true);
// Fetch websites
const { data: websites, error: websitesError } = await supabase
.from("websites")
.select(
`
id,
name,
base_url,
is_active,
created_at,
pages!inner (
id,
is_active
)
`,
)
.eq("organization_id", userDetails.organization_id)
.eq("is_active", true);
if (websitesError) throw websitesError;
// Fetch recent scans
let recentScans: any[] = [];
try {
recentScans = await scanService.getRecentScans(10);
} catch (error) {
console.log("No scans found yet:", error);
recentScans = [];
}
// Calculate stats
const websitesCount = websites?.length || 0;
const activePages =
websites?.reduce(
(sum, website) =>
sum + (website.pages?.filter((p: any) => p.is_active).length || 0),
0,
) || 0;
const totalScans = recentScans.length;
const completedScans = recentScans.filter(
(scan) => scan.status === "completed",
);
const averagePerformance =
completedScans.length > 0
? Math.round(
completedScans.reduce(
(sum, scan) => sum + (scan.performance_score || 0),
0,
) / completedScans.length,
)
: 0;
const lastScan = recentScans[0];
const lastScanTime = lastScan
? new Date(lastScan.created_at).toLocaleString()
: "Never";
setStats({
websitesCount,
activePages,
totalScans,
averagePerformance,
lastScanTime,
recentScans: recentScans.slice(0, 5),
websites: websites || [],
});
} catch (error) {
console.error("Failed to load dashboard data:", error);
} finally {
setLoading(false);
setRefreshing(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
await loadDashboardData();
};
const getScoreColor = (score: number) => {
if (score >= 90) return "text-green-600";
if (score >= 70) return "text-yellow-600";
return "text-red-600";
};
const getScoreBadgeColor = (score: number) => {
if (score >= 90) return "bg-green-100 text-green-800";
if (score >= 70) return "bg-yellow-100 text-yellow-800";
return "bg-red-100 text-red-800";
};
const getStatusIcon = (status: string) => {
switch (status) {
case "completed":
return <CheckCircle className="w-4 h-4 text-green-600" />;
case "running":
return <Activity className="w-4 h-4 text-blue-600 animate-pulse" />;
case "failed":
return <AlertCircle className="w-4 h-4 text-red-600" />;
default:
return <Clock className="w-4 h-4 text-gray-600" />;
}
};
// Show loading only when absolutely necessary
if (shouldShowLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<div className="flex items-center space-x-2">
<RefreshCw className="w-6 h-6 animate-spin text-blue-600" />
<span className="text-gray-600">Loading dashboard...</span>
</div>
</div>
</DashboardLayout>
);
}
const quickStats = [
{
label: "Websites Monitored",
value: stats?.websitesCount?.toString() || "0",
change: `${stats?.activePages || 0} active pages`,
trend: stats?.websitesCount ? "up" : "stable",
icon: Globe,
color: "blue",
},
{
label: "Average Performance",
value: stats?.averagePerformance ? `${stats.averagePerformance}%` : "N/A",
change:
(stats?.averagePerformance ?? 0) >= 90
? "Excellent"
: (stats?.averagePerformance ?? 0) >= 70
? "Good"
: "Needs improvement",
trend:
(stats?.averagePerformance ?? 0) >= 90
? "up"
: (stats?.averagePerformance ?? 0) >= 70
? "stable"
: "down",
icon: Zap,
color: "green",
},
{
label: "Total Scans",
value: stats?.totalScans?.toString() || "0",
change: "All time",
trend: "stable",
icon: Search,
color: "purple",
},
{
label: "Last Scan",
value: stats?.lastScanTime === "Never" ? "Never" : "Recent",
change: stats?.lastScanTime || "No scans yet",
trend: "stable",
icon: Clock,
color: "gray",
},
];
return (
<DashboardLayout>
<div className="space-y-8">
{/* Database Setup Helper */}
<DatabaseSetupHelper />
{/* Supabase Diagnostic */}
<SupabaseDiagnostic />
{/* Welcome Header */}
<div className="flex items-center justify-between">
<div>
<motion.h1
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="text-3xl font-bold text-gray-900"
>
Welcome back, {userDetails?.name?.split(" ")[0] || "User"}!
</motion.h1>
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-gray-600 mt-2"
>
Monitor your website performance and SEO in real-time
</motion.p>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 }}
className="flex gap-3"
>
<Button
variant="outline"
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-2"
>
<RefreshCw
className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
onClick={() => router.push("/dashboard/websites/new")}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Website
</Button>
</motion.div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{quickStats.map((stat, index) => {
const Icon = stat.icon;
return (
<motion.div
key={stat.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="hover:shadow-lg transition-all duration-200 hover:scale-105">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">
{stat.label}
</p>
<p className="text-2xl font-bold text-gray-900 mt-1">
{stat.value}
</p>
<div className="flex items-center mt-2">
{stat.trend === "up" && (
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
)}
{stat.trend === "down" && (
<TrendingDown className="w-3 h-3 text-red-500 mr-1" />
)}
<p
className={`text-xs ${
stat.trend === "up"
? "text-green-600"
: stat.trend === "down"
? "text-red-600"
: "text-gray-500"
}`}
>
{stat.change}
</p>
</div>
</div>
<div
className={`p-3 rounded-lg bg-${stat.color}-100 flex-shrink-0`}
>
<Icon className={`w-5 h-5 text-${stat.color}-600`} />
</div>
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Scans */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="h-full">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">
Recent Scans
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/dashboard/scans")}
className="flex items-center gap-1"
>
View All
<ArrowRight className="w-3 h-3" />
</Button>
</div>
<div className="space-y-4">
{(stats?.recentScans?.length ?? 0) > 0 ? (
stats?.recentScans?.map((scan, index) => (
<motion.div
key={scan.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-3">
{getStatusIcon(scan.status)}
<div>
<p className="font-medium text-gray-900 truncate max-w-48">
{scan.pages?.title ||
scan.pages?.url ||
"Unknown Page"}
</p>
<p className="text-sm text-gray-500">
{new Date(scan.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{scan.performance_score && (
<Badge
className={getScoreBadgeColor(
scan.performance_score,
)}
>
{scan.performance_score}%
</Badge>
)}
<Badge variant="gray" className="text-xs">
{scan.status}
</Badge>
</div>
</motion.div>
))
) : (
<div className="text-center py-8 text-gray-500">
<Activity className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>No scans yet</p>
<p className="text-sm">
Start monitoring your websites to see scan results
</p>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
{/* Websites Overview */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
>
<Card className="h-full">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-green-600" />
<h2 className="text-xl font-semibold text-gray-900">
Your Websites
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/dashboard/websites")}
className="flex items-center gap-1"
>
Manage All
<ArrowRight className="w-3 h-3" />
</Button>
</div>
<div className="space-y-4">
{(stats?.websites?.length ?? 0) > 0 ? (
stats?.websites?.slice(0, 5).map((website, index) => (
<motion.div
key={website.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 + index * 0.1 }}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
onClick={() =>
router.push(`/dashboard/websites/${website.id}`)
}
>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<Globe className="w-4 h-4 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900">
{website.name}
</p>
<p className="text-sm text-gray-500 flex items-center gap-1">
{website.base_url}
<ExternalLink className="w-3 h-3" />
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant="green" className="text-xs">
{website.pages?.filter((p: any) => p.is_active)
.length || 0}{" "}
pages
</Badge>
<Badge
className={
website.is_active
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{website.is_active ? "Active" : "Inactive"}
</Badge>
</div>
</motion.div>
))
) : (
<div className="text-center py-8 text-gray-500">
<Globe className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p>No websites added yet</p>
<Button
onClick={() => router.push("/dashboard/websites/new")}
className="mt-4 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Your First Website
</Button>
</div>
)}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Quick Actions
</h3>
<p className="text-gray-600">
Get started with monitoring your websites
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => router.push("/dashboard/websites")}
className="flex items-center gap-2"
>
<Globe className="w-4 h-4" />
View Websites
</Button>
<Button
variant="outline"
onClick={() => router.push("/dashboard/scans")}
className="flex items-center gap-2"
>
<BarChart3 className="w-4 h-4" />
View Reports
</Button>
<Button
onClick={() => router.push("/dashboard/websites/new")}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Add Website
</Button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,400 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } 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 {
Zap,
TrendingUp,
TrendingDown,
Clock,
Target,
AlertTriangle,
CheckCircle,
BarChart3,
Loader2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
import { logError, getUserFriendlyErrorMessage, extractSupabaseErrorInfo } from "@/utils/errorUtils";
interface PerformanceMetric {
id: string;
website_name: string;
website_url: string;
lighthouse_score: number;
performance_score: number;
accessibility_score: number;
best_practices_score: number;
seo_score: number;
first_contentful_paint: number;
largest_contentful_paint: number;
cumulative_layout_shift: number;
total_blocking_time: number;
created_at: string;
}
interface PerformanceSummary {
totalWebsites: number;
averageScore: number;
goodPerformance: number;
needsImprovement: number;
poor: number;
}
export default function PerformancePage() {
const { userDetails } = useAuth();
const [metrics, setMetrics] = useState<PerformanceMetric[]>([]);
const [summary, setSummary] = useState<PerformanceSummary>({
totalWebsites: 0,
averageScore: 0,
goodPerformance: 0,
needsImprovement: 0,
poor: 0,
});
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
useEffect(() => {
if (userDetails?.organization_id) {
loadPerformanceData();
}
}, [userDetails, timeRange]);
const loadPerformanceData = async () => {
if (!userDetails?.organization_id) return;
try {
setLoading(true);
// Calculate date range
const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90;
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
// Fetch latest performance data for each website
const { data: scanData, error } = await supabase
.from("scans")
.select(`
id,
lighthouse_score,
created_at,
scan_results!inner (
category,
score,
metrics
),
pages!inner (
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) {
const errorInfo = extractSupabaseErrorInfo(error);
logError("Error loading performance data", error, {
organizationId: userDetails.organization_id,
timeRange,
startDate: startDate.toISOString(),
supabaseError: errorInfo
});
// If tables don't exist, set empty metrics
if (errorInfo.message?.includes("does not exist") || errorInfo.details?.includes("does not exist")) {
setMetrics([]);
setSummary({
totalWebsites: 0,
averageScore: 0,
goodPerformance: 0,
needsImprovement: 0,
poor: 0,
});
return;
}
throw error;
}
// Process the data to get latest metrics per website
const websiteMetrics = new Map<string, PerformanceMetric>();
scanData?.forEach((scan: any) => {
const website = scan.pages.websites;
if (!websiteMetrics.has(website.id)) {
const results = scan.scan_results || [];
websiteMetrics.set(website.id, {
id: scan.id,
website_name: website.name,
website_url: website.base_url,
lighthouse_score: scan.lighthouse_score || 0,
performance_score: results.find((r: any) => r.category === "performance")?.score || 0,
accessibility_score: results.find((r: any) => r.category === "accessibility")?.score || 0,
best_practices_score: results.find((r: any) => r.category === "best-practices")?.score || 0,
seo_score: results.find((r: any) => r.category === "seo")?.score || 0,
first_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.first_contentful_paint || 0,
largest_contentful_paint: results.find((r: any) => r.category === "performance")?.metrics?.largest_contentful_paint || 0,
cumulative_layout_shift: results.find((r: any) => r.category === "performance")?.metrics?.cumulative_layout_shift || 0,
total_blocking_time: results.find((r: any) => r.category === "performance")?.metrics?.total_blocking_time || 0,
created_at: scan.created_at,
});
}
});
const metricsArray = Array.from(websiteMetrics.values());
setMetrics(metricsArray);
// Calculate summary
if (metricsArray.length > 0) {
const avgScore = Math.round(
metricsArray.reduce((sum, m) => sum + m.lighthouse_score, 0) / metricsArray.length
);
const good = metricsArray.filter(m => m.lighthouse_score >= 90).length;
const needsImprovement = metricsArray.filter(m => m.lighthouse_score >= 50 && m.lighthouse_score < 90).length;
const poor = metricsArray.filter(m => m.lighthouse_score < 50).length;
setSummary({
totalWebsites: metricsArray.length,
averageScore: avgScore,
goodPerformance: good,
needsImprovement,
poor,
});
}
} catch (error) {
const errorInfo = extractSupabaseErrorInfo(error);
logError("Error loading performance data", error, {
organizationId: userDetails.organization_id,
timeRange,
function: "loadPerformanceData",
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">
<Zap className="w-6 h-6" />
Performance Overview
</h1>
<p className="text-gray-600 mt-1">
Monitor and analyze your websites&apos; performance metrics
</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 Websites</p>
<p className="text-2xl font-bold">{summary.totalWebsites}</p>
</div>
<BarChart3 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 Score</p>
<p className="text-2xl font-bold">{summary.averageScore}</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 Performance</p>
<p className="text-2xl font-bold text-green-600">{summary.goodPerformance}</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>
{/* Performance Metrics */}
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Website Performance</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>
<h3 className="font-semibold text-gray-900">{metric.website_name}</h3>
<p className="text-sm text-gray-500">{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.lighthouse_score)}`}>
{getScoreIcon(metric.lighthouse_score)}
{metric.lighthouse_score}/100
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center">
<div className={`text-lg font-semibold ${getScoreColor(metric.performance_score).split(' ')[0]}`}>
{metric.performance_score}
</div>
<div className="text-xs text-gray-500">Performance</div>
</div>
<div className="text-center">
<div className={`text-lg font-semibold ${getScoreColor(metric.accessibility_score).split(' ')[0]}`}>
{metric.accessibility_score}
</div>
<div className="text-xs text-gray-500">Accessibility</div>
</div>
<div className="text-center">
<div className={`text-lg font-semibold ${getScoreColor(metric.best_practices_score).split(' ')[0]}`}>
{metric.best_practices_score}
</div>
<div className="text-xs text-gray-500">Best Practices</div>
</div>
<div className="text-center">
<div className={`text-lg font-semibold ${getScoreColor(metric.seo_score).split(' ')[0]}`}>
{metric.seo_score}
</div>
<div className="text-xs text-gray-500">SEO</div>
</div>
</div>
{(metric.first_contentful_paint > 0 || metric.largest_contentful_paint > 0) && (
<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">
{metric.first_contentful_paint > 0 && (
<div>
<div className="font-medium text-gray-900">
{(metric.first_contentful_paint / 1000).toFixed(1)}s
</div>
<div className="text-gray-500">FCP</div>
</div>
)}
{metric.largest_contentful_paint > 0 && (
<div>
<div className="font-medium text-gray-900">
{(metric.largest_contentful_paint / 1000).toFixed(1)}s
</div>
<div className="text-gray-500">LCP</div>
</div>
)}
{metric.cumulative_layout_shift > 0 && (
<div>
<div className="font-medium text-gray-900">
{metric.cumulative_layout_shift.toFixed(3)}
</div>
<div className="text-gray-500">CLS</div>
</div>
)}
{metric.total_blocking_time > 0 && (
<div>
<div className="font-medium text-gray-900">
{metric.total_blocking_time}ms
</div>
<div className="text-gray-500">TBT</div>
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
))}
</div>
) : (
<Card>
<CardContent className="p-12 text-center">
<Zap className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No performance data available
</h3>
<p className="text-gray-600">
Run scans on your websites to see performance metrics here
</p>
</CardContent>
</Card>
)}
</div>
</div>
</DashboardLayout>
);
}
@@ -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&apos; 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>
);
}
@@ -0,0 +1,576 @@
"use client";
import { useState, useEffect } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Badge } from "@/components/ui/layout/Badge";
import {
Settings,
User,
Bell,
Shield,
CreditCard,
Key,
Mail,
Smartphone,
Globe,
Database,
Zap,
Check,
X,
Loader2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
interface UserSettings {
email_notifications: boolean;
sms_notifications: boolean;
browser_notifications: boolean;
weekly_report: boolean;
timezone: string;
date_format: string;
}
interface OrganizationSettings {
name: string;
subscription_tier: string;
subscription_status: string;
max_websites: number;
max_scans_per_month: number;
api_key: string;
}
export default function SettingsPage() {
const { user, userDetails } = useAuth();
const [activeTab, setActiveTab] = useState<"profile" | "notifications" | "organization" | "billing" | "api">("profile");
const [userSettings, setUserSettings] = useState<UserSettings>({
email_notifications: true,
sms_notifications: false,
browser_notifications: true,
weekly_report: true,
timezone: "UTC",
date_format: "MM/DD/YYYY",
});
const [orgSettings, setOrgSettings] = useState<OrganizationSettings | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
useEffect(() => {
loadSettings();
}, [userDetails]);
const loadSettings = async () => {
if (!userDetails?.organization_id) return;
try {
setLoading(true);
// Load organization settings
const { data: orgData, error: orgError } = await supabase
.from("organizations")
.select("*")
.eq("id", userDetails.organization_id)
.single();
if (orgError) throw orgError;
if (orgData) {
setOrgSettings({
name: orgData.name,
subscription_tier: orgData.subscription_tier,
subscription_status: orgData.subscription_status,
max_websites: orgData.max_websites || 10,
max_scans_per_month: orgData.max_scans_per_month || 1000,
api_key: orgData.api_key || "sk-" + Math.random().toString(36).substring(2, 15),
});
}
// Load user notification preferences (if they exist)
const { data: notificationData } = await supabase
.from("user_notification_preferences")
.select("*")
.eq("user_id", user?.id)
.single();
if (notificationData) {
setUserSettings({
email_notifications: notificationData.email_notifications,
sms_notifications: notificationData.sms_notifications,
browser_notifications: notificationData.browser_notifications,
weekly_report: notificationData.weekly_report,
timezone: notificationData.timezone || "UTC",
date_format: notificationData.date_format || "MM/DD/YYYY",
});
}
} catch (error) {
console.error("Error loading settings:", error);
} finally {
setLoading(false);
}
};
const saveUserSettings = async () => {
if (!user?.id) return;
try {
setSaving(true);
setError("");
const { error } = await supabase
.from("user_notification_preferences")
.upsert({
user_id: user.id,
...userSettings,
});
if (error) throw error;
setSuccess("Settings saved successfully");
setTimeout(() => setSuccess(""), 3000);
} catch (error) {
console.error("Error saving settings:", error);
setError("Failed to save settings");
} finally {
setSaving(false);
}
};
const saveOrgSettings = async () => {
if (!userDetails?.organization_id || !orgSettings) return;
try {
setSaving(true);
setError("");
const { error } = await supabase
.from("organizations")
.update({
name: orgSettings.name,
})
.eq("id", userDetails.organization_id);
if (error) throw error;
setSuccess("Organization settings saved successfully");
setTimeout(() => setSuccess(""), 3000);
} catch (error) {
console.error("Error saving organization settings:", error);
setError("Failed to save organization settings");
} finally {
setSaving(false);
}
};
const generateNewApiKey = async () => {
if (!userDetails?.organization_id) return;
try {
setSaving(true);
const newApiKey = "sk-" + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
const { error } = await supabase
.from("organizations")
.update({ api_key: newApiKey })
.eq("id", userDetails.organization_id);
if (error) throw error;
setOrgSettings(prev => prev ? { ...prev, api_key: newApiKey } : null);
setSuccess("New API key generated successfully");
setTimeout(() => setSuccess(""), 3000);
} catch (error) {
console.error("Error generating API key:", error);
setError("Failed to generate new API key");
} finally {
setSaving(false);
}
};
const tabs = [
{ id: "profile", label: "Profile", icon: User },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "organization", label: "Organization", icon: Globe },
{ id: "billing", label: "Billing", icon: CreditCard },
{ id: "api", label: "API", icon: Key },
];
return (
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Settings className="w-6 h-6" />
Settings
</h1>
<p className="text-gray-600 mt-1">
Manage your account and organization preferences
</p>
</div>
{/* Success/Error Messages */}
{success && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
<Check className="w-5 h-5 text-green-500" />
<span className="text-green-800">{success}</span>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
<X className="w-5 h-5 text-red-500" />
<span className="text-red-800">{error}</span>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.id
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="space-y-6">
{activeTab === "profile" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="w-5 h-5" />
Profile Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
type="text"
value={userDetails?.name || ""}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
readOnly
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
type="email"
value={user?.email || ""}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
readOnly
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Role
</label>
<Badge className="bg-blue-100 text-blue-800">
{userDetails?.role?.toUpperCase()}
</Badge>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Member Since
</label>
<span className="text-gray-600">
</span>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === "notifications" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="w-5 h-5" />
Notification Preferences
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Email Notifications</p>
<p className="text-sm text-gray-500">Receive alerts and updates via email</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={userSettings.email_notifications}
onChange={(e) => setUserSettings(prev => ({ ...prev, email_notifications: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Smartphone className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">SMS Notifications</p>
<p className="text-sm text-gray-500">Receive urgent alerts via SMS</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={userSettings.sms_notifications}
onChange={(e) => setUserSettings(prev => ({ ...prev, sms_notifications: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Browser Notifications</p>
<p className="text-sm text-gray-500">Show desktop notifications in your browser</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={userSettings.browser_notifications}
onChange={(e) => setUserSettings(prev => ({ ...prev, browser_notifications: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Weekly Reports</p>
<p className="text-sm text-gray-500">Receive weekly performance summaries</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={userSettings.weekly_report}
onChange={(e) => setUserSettings(prev => ({ ...prev, weekly_report: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="pt-4 border-t">
<Button
onClick={saveUserSettings}
disabled={saving}
className="flex items-center gap-2"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
Save Preferences
</Button>
</div>
</CardContent>
</Card>
)}
{activeTab === "organization" && orgSettings && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="w-5 h-5" />
Organization Settings
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Organization Name
</label>
<input
type="text"
value={orgSettings.name}
onChange={(e) => setOrgSettings({ ...orgSettings, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Subscription Plan
</label>
<Badge className="bg-purple-100 text-purple-800">
{orgSettings.subscription_tier.toUpperCase()}
</Badge>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<Badge className={orgSettings.subscription_status === "active" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}>
{orgSettings.subscription_status.toUpperCase()}
</Badge>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Websites
</label>
<span className="text-gray-600">{orgSettings.max_websites}</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Scans per Month
</label>
<span className="text-gray-600">{orgSettings.max_scans_per_month.toLocaleString()}</span>
</div>
</div>
<div className="pt-4 border-t">
<Button
onClick={saveOrgSettings}
disabled={saving}
className="flex items-center gap-2"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
Save Changes
</Button>
</div>
</CardContent>
</Card>
)}
{activeTab === "billing" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="w-5 h-5" />
Billing & Subscription
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-12">
<CreditCard className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Billing Management
</h3>
<p className="text-gray-600 mb-6">
Billing features are not yet implemented in this demo
</p>
<Button variant="outline">
Contact Support
</Button>
</div>
</CardContent>
</Card>
)}
{activeTab === "api" && orgSettings && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
API Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<div className="flex gap-2">
<input
type="text"
value={orgSettings.api_key}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
readOnly
/>
<Button
variant="outline"
onClick={generateNewApiKey}
disabled={saving}
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Regenerate"
)}
</Button>
</div>
<p className="text-sm text-gray-500 mt-1">
Use this API key to authenticate requests to our API
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">API Endpoints</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<code className="text-blue-600">GET /api/websites</code>
<span className="text-gray-500">List websites</span>
</div>
<div className="flex justify-between">
<code className="text-blue-600">POST /api/websites/{"{id}"}/scan</code>
<span className="text-gray-500">Trigger scan</span>
</div>
<div className="flex justify-between">
<code className="text-blue-600">GET /api/scans/{"{id}"}</code>
<span className="text-gray-500">Get scan results</span>
</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,398 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/layout/Card";
import { Button } from "@/components/ui/forms/Button";
import { Badge } from "@/components/ui/layout/Badge";
import {
Users,
Plus,
Mail,
Settings,
Trash2,
Crown,
Shield,
User,
MoreVertical,
Check,
X,
Loader2,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { supabase } from "@/lib/supabase";
interface TeamMember {
id: string;
email: string;
name: string;
role: "owner" | "admin" | "member";
status: "active" | "pending";
created_at: string;
last_login_at?: string;
}
export default function TeamPage() {
const router = useRouter();
const { userDetails, user } = useAuth();
const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true);
const [inviting, setInviting] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<"admin" | "member">("member");
const [success, setSuccess] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (userDetails?.organization_id) {
loadTeamMembers();
}
}, [userDetails]);
const loadTeamMembers = async () => {
if (!userDetails?.organization_id) return;
try {
setLoading(true);
const { data, error } = await supabase
.from("users")
.select("*")
.eq("organization_id", userDetails.organization_id)
.order("created_at", { ascending: false });
if (error) {
console.error("Error loading team members:", error);
// If it's a missing table error, set empty array
if (error.message?.includes("does not exist")) {
setMembers([]);
return;
}
throw error;
}
setMembers(data || []);
} catch (error) {
console.error("Error loading team members:", error);
setError("Failed to load team members");
} finally {
setLoading(false);
}
};
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || !userDetails?.organization_id) return;
try {
setInviting(true);
setError("");
// Check if user already exists
const { data: existingUser } = await supabase
.from("users")
.select("id")
.eq("email", inviteEmail.toLowerCase())
.single();
if (existingUser) {
setError("User is already a member of an organization");
return;
}
// Send invitation (in a real app, you'd send an email)
// For now, we'll create a pending user record
const { error: inviteError } = await supabase
.from("team_invitations")
.insert([
{
email: inviteEmail.toLowerCase(),
role: inviteRole,
organization_id: userDetails.organization_id,
invited_by: user?.id,
status: "pending",
},
]);
if (inviteError) throw inviteError;
setSuccess(`Invitation sent to ${inviteEmail}`);
setInviteEmail("");
setInviteRole("member");
await loadTeamMembers();
} catch (error) {
console.error("Error inviting member:", error);
setError("Failed to send invitation");
} finally {
setInviting(false);
}
};
const handleRemoveMember = async (memberId: string) => {
if (!confirm("Are you sure you want to remove this team member?")) return;
try {
const { error } = await supabase
.from("users")
.delete()
.eq("id", memberId);
if (error) throw error;
setSuccess("Team member removed successfully");
await loadTeamMembers();
} catch (error) {
console.error("Error removing member:", error);
setError("Failed to remove team member");
}
};
const handleUpdateRole = async (memberId: string, newRole: string) => {
try {
const { error } = await supabase
.from("users")
.update({ role: newRole })
.eq("id", memberId);
if (error) throw error;
setSuccess("Member role updated successfully");
await loadTeamMembers();
} catch (error) {
console.error("Error updating role:", error);
setError("Failed to update member role");
}
};
const getRoleIcon = (role: string) => {
switch (role) {
case "owner":
return <Crown className="w-4 h-4 text-yellow-500" />;
case "admin":
return <Shield className="w-4 h-4 text-blue-500" />;
default:
return <User className="w-4 h-4 text-gray-500" />;
}
};
const getRoleBadgeColor = (role: string) => {
switch (role) {
case "owner":
return "bg-yellow-100 text-yellow-800";
case "admin":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const canManageMembers = userDetails?.role === "owner" || userDetails?.role === "admin";
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">
{/* Success/Error Messages */}
<AnimatePresence>
{success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3"
>
<Check className="w-5 h-5 text-green-500" />
<span className="text-green-800">{success}</span>
<Button
variant="outline"
size="sm"
onClick={() => setSuccess("")}
className="ml-auto"
>
Dismiss
</Button>
</motion.div>
)}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3"
>
<X className="w-5 h-5 text-red-500" />
<span className="text-red-800">{error}</span>
<Button
variant="outline"
size="sm"
onClick={() => setError("")}
className="ml-auto"
>
Dismiss
</Button>
</motion.div>
)}
</AnimatePresence>
{/* 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">
<Users className="w-6 h-6" />
Team Members ({members.length})
</h1>
<p className="text-gray-600 mt-1">
Manage your organization&apos;s team members and permissions
</p>
</div>
{canManageMembers && (
<Button
onClick={() => document.getElementById("invite-form")?.scrollIntoView()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Invite Member
</Button>
)}
</div>
{/* Invite Form */}
{canManageMembers && (
<Card id="invite-form">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
Invite New Member
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleInviteMember} className="flex gap-4">
<input
type="email"
placeholder="Enter email address"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as "admin" | "member")}
className="px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<Button type="submit" disabled={inviting}>
{inviting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Send Invite"
)}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Team Members List */}
<div className="grid gap-4">
{members.map((member) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="group"
>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-gray-600" />
</div>
<div>
<h3 className="font-semibold text-gray-900">{member.name}</h3>
<p className="text-sm text-gray-500">{member.email}</p>
{member.last_login_at && (
<p className="text-xs text-gray-400">
Last login: {new Date(member.last_login_at).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Badge className={`flex items-center gap-1 ${getRoleBadgeColor(member.role)}`}>
{getRoleIcon(member.role)}
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</Badge>
{canManageMembers && member.id !== user?.id && (
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{member.role !== "owner" && (
<select
value={member.role}
onChange={(e) => handleUpdateRole(member.id, e.target.value)}
className="text-sm px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
)}
{member.role !== "owner" && (
<Button
variant="outline"
size="sm"
onClick={() => handleRemoveMember(member.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
)}
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
{members.length === 0 && (
<Card>
<CardContent className="p-12 text-center">
<Users className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No team members yet
</h3>
<p className="text-gray-600 mb-6">
Start building your team by inviting members to your organization
</p>
{canManageMembers && (
<Button
onClick={() => document.getElementById("invite-form")?.scrollIntoView()}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Invite Your First Member
</Button>
)}
</CardContent>
</Card>
)}
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,485 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { WebsiteSettings } from "@/components/dashboard/WebsiteSettings";
import { CrawlerControl } from "@/components/dashboard/CrawlerControl";
import { CrawlDebugger } from "@/components/debug/CrawlDebugger";
import { Button } from "@/components/ui/forms/Button";
import { Card, CardContent } from "@/components/ui/layout/Card";
import { Badge } from "@/components/ui/layout/Badge";
import {
Trash2,
Globe,
Calendar,
Activity,
FileText,
Search,
AlertCircle,
CheckCircle,
Clock,
ExternalLink,
Settings,
Play,
Bug,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { websiteService } from "@/services/websiteService";
import { ScanScheduleManager } from '@/components/dashboard/ScanScheduleManager';
interface WebsiteData {
id: string;
name: string;
base_url: string;
is_active: boolean;
created_at: string;
organization_id: string;
stats: {
pagesCount: number;
scansCount: number;
latestScan: {
id: string;
status: string;
created_at: string;
} | null;
};
}
// Custom hook to handle async params
function useAsyncParams<T>(params: Promise<T> | T): T | null {
const [resolvedParams, setResolvedParams] = useState<T | null>(null);
useEffect(() => {
const resolveParams = async () => {
try {
const resolved = await Promise.resolve(params);
setResolvedParams(resolved);
} catch (error) {
console.error("Failed to resolve params:", error);
}
};
resolveParams();
}, [params]);
return resolvedParams;
}
export default function WebsiteDetailsPage(props: any) {
// Handle async params properly for Next.js 15+
const [websiteId, setWebsiteId] = useState<string | null>(null);
useEffect(() => {
const resolveParams = async () => {
try {
const params = await Promise.resolve(props?.params);
setWebsiteId(params?.id || null);
} catch (error) {
console.error("Failed to resolve params:", error);
}
};
resolveParams();
}, [props?.params]);
const [website, setWebsite] = useState<WebsiteData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
const [activeSection, setActiveSection] = useState("overview");
const router = useRouter();
const loadWebsiteData = useCallback(async () => {
if (!websiteId) return;
try {
setLoading(true);
const data = await websiteService.getWebsite(websiteId);
setWebsite(data as WebsiteData);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to load website data",
);
} finally {
setLoading(false);
}
}, [websiteId]);
useEffect(() => {
loadWebsiteData();
}, [loadWebsiteData]);
const handleDelete = async () => {
if (!websiteId) return;
setDeleting(true);
try {
const { error } = await supabase
.from("websites")
.delete()
.eq("id", websiteId);
if (error) {
alert("Failed to delete website: " + error.message);
} else {
router.push("/dashboard/websites");
}
} catch (err: unknown) {
alert(
"Failed to delete website: " +
(err instanceof Error ? err.message : String(err)),
);
} finally {
setDeleting(false);
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusBadge = (status: string) => {
const statusConfig = {
completed: { color: "green", icon: CheckCircle },
running: { color: "blue", icon: Clock },
failed: { color: "red", icon: AlertCircle },
pending: { color: "yellow", icon: Clock },
};
const config =
statusConfig[status as keyof typeof statusConfig] || statusConfig.pending;
const Icon = config.icon;
return (
<Badge
variant={config.color as "green" | "blue" | "red" | "yellow"}
className="flex items-center gap-1"
>
<Icon className="w-3 h-3" />
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
);
};
if (loading || !websiteId) {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p>{!websiteId ? "Loading..." : "Loading website details..."}</p>
</div>
</div>
</DashboardLayout>
);
}
if (error || !website) {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-96">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
<p className="text-red-600">{error || "Website not found"}</p>
<Button
onClick={() => router.push("/dashboard/websites")}
className="mt-4"
>
Back to Websites
</Button>
</div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="max-w-7xl mx-auto space-y-6">
{/* Header Section */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(website.base_url)}`}
alt="Website favicon"
className="w-12 h-12 rounded-lg border shadow-sm"
/>
<div>
<h1 className="text-3xl font-bold text-gray-900">
{website.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Globe className="w-4 h-4 text-gray-500" />
<a
href={website.base_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
>
{website.base_url}
<ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
</div>
<Badge variant={website.is_active ? "green" : "gray"}>
{website.is_active ? "Active" : "Inactive"}
</Badge>
</div>
{/* Navigation Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
{[
{ id: "overview", label: "Overview", icon: Activity },
{ id: "crawler", label: "Crawler Control", icon: Play },
{ id: "debug", label: "Debug", icon: Bug },
{ id: "settings", label: "Settings", icon: Settings },
{ id: "danger", label: "Danger Zone", icon: Trash2 },
].map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveSection(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeSection === tab.id
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Content Sections */}
{activeSection === "overview" && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Statistics Cards */}
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">
Total Pages
</p>
<p className="text-2xl font-bold text-gray-900">
{website.stats.pagesCount}
</p>
</div>
<FileText className="w-8 h-8 text-blue-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">
Total Scans
</p>
<p className="text-2xl font-bold text-gray-900">
{website.stats.scansCount}
</p>
</div>
<Search className="w-8 h-8 text-green-500" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">
Status
</p>
<div className="mt-1">
{website.stats.latestScan ? (
getStatusBadge(website.stats.latestScan.status)
) : (
<Badge variant="gray">No scans</Badge>
)}
</div>
</div>
<Activity className="w-8 h-8 text-purple-500" />
</div>
</CardContent>
</Card>
</div>
{/* Website Information */}
<Card>
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-4">
Website Information
</h3>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">Created</p>
<p className="font-medium flex items-center gap-2">
<Calendar className="w-4 h-4" />
{formatDate(website.created_at)}
</p>
</div>
{website.stats.latestScan && (
<div>
<p className="text-sm text-gray-600">Last Scan</p>
<p className="font-medium flex items-center gap-2">
<Clock className="w-4 h-4" />
{formatDate(website.stats.latestScan.created_at)}
</p>
</div>
)}
<div>
<p className="text-sm text-gray-600">Website ID</p>
<p className="font-mono text-sm bg-gray-100 p-2 rounded">
{website.id}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="lg:col-span-3">
<CardContent className="p-6">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
{website.stats.latestScan ? (
<div className="border-l-4 border-blue-500 pl-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Latest Scan Completed</p>
<p className="text-sm text-gray-600">
Scan ID: {website.stats.latestScan.id}
</p>
</div>
<div className="text-right">
{getStatusBadge(website.stats.latestScan.status)}
<p className="text-sm text-gray-500 mt-1">
{formatDate(website.stats.latestScan.created_at)}
</p>
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic">No recent activity</p>
)}
</CardContent>
</Card>
</div>
)}
{activeSection === "crawler" && websiteId && (
<div className="max-w-4xl mx-auto">
<Card>
<CardContent className="p-6">
<h2 className="text-2xl font-bold mb-6">
Crawler Control Panel
</h2>
<CrawlerControl websiteId={websiteId} />
</CardContent>
</Card>
</div>
)}
{activeSection === "debug" && websiteId && (
<div className="max-w-6xl mx-auto">
<CrawlDebugger websiteId={websiteId} />
</div>
)}
{activeSection === "settings" && websiteId && (
<div className="max-w-4xl mx-auto">
<WebsiteSettings websiteId={websiteId} />
</div>
)}
{activeSection === "danger" && websiteId && (
<div className="max-w-4xl mx-auto">
<Card className="border-red-200">
<CardContent className="p-6">
<h2 className="text-2xl font-bold text-red-700 mb-6">
Danger Zone
</h2>
<div className="bg-red-50 border border-red-200 p-6 rounded-lg">
<h3 className="text-lg font-semibold text-red-800 mb-3">
Delete Website
</h3>
<p className="text-sm text-red-700 mb-4">
Once you delete a website, there is no going back. This will
permanently delete the website and all associated data
including scans, pages, and analytics.
</p>
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Website
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-2">Delete Website</h3>
<p className="text-sm text-gray-700 mb-2">
To{" "}
<span className="font-bold text-red-600">
permanently delete
</span>{" "}
<span className="font-bold">{website.name}</span> and{" "}
<span className="font-bold">all its data</span>, type{" "}
<span className="font-bold">DELETE</span> below and confirm.
</p>
<input
className="border rounded px-3 py-2 w-full mb-4"
placeholder="Type DELETE to confirm"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
autoFocus
/>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText("");
}}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting || deleteConfirmText !== "DELETE"}
>
{deleting ? "Deleting..." : "Delete Website"}
</Button>
</div>
</div>
</div>
)}
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,27 @@
"use client";
import { useState, useEffect } from "react";
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { WebsiteSettings } from "@/components/dashboard/WebsiteSettings";
export default function WebsiteSettingsPage(props: any) {
const [id, setId] = useState<string | null>(null);
useEffect(() => {
const resolveParams = async () => {
try {
const params = await Promise.resolve(props?.params);
setId(params?.id || null);
} catch (error) {
console.error("Failed to resolve params:", error);
}
};
resolveParams();
}, [props?.params]);
return (
<DashboardLayout>
{id ? <WebsiteSettings websiteId={id} /> : <div>Loading...</div>}
</DashboardLayout>
);
}
@@ -0,0 +1,12 @@
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { AddWebsiteForm } from "@/components/dashboard/AddWebsiteForm";
export default function AddWebsitePage() {
return (
<DashboardLayout>
<div className="py-6">
<AddWebsiteForm />
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,13 @@
import { DashboardLayout } from "@/components/layouts/DashboardLayout";
import { EnhancedWebsiteList } from "@/components/dashboard/EnhancedWebsiteList";
import { ScanScheduleManager } from '@/components/dashboard/ScanScheduleManager';
export default function WebsitesPage() {
return (
<DashboardLayout>
<div className="py-6">
<EnhancedWebsiteList />
</div>
</DashboardLayout>
);
}