feat: initialize monorepo with full dev team best practices
- Unified monorepo with backend (Express), frontend (Next.js), and devops - Backend: ESLint, Prettier, Jest tests (3 passing), health endpoint, .env.example - Frontend: Fixed build errors, fixed all lint errors (0 remaining), tests passing - DevOps: Docker Compose with PostgreSQL, backend, frontend + healthchecks - CI/CD: 3 GitHub Actions workflows (backend, frontend, docker integration) - DX: Husky pre-commit hooks with smart change detection - Docs: Root README with architecture, CONTRIBUTING.md, PR template Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { pageId, websiteId, triggerType = "manual" } = await request.json();
|
||||
|
||||
if (!pageId && !websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Either pageId or websiteId must be provided" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let pagesToScan = [];
|
||||
|
||||
if (pageId) {
|
||||
// Scan specific page
|
||||
const { data: page, error: pageError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("id", pageId)
|
||||
.single();
|
||||
|
||||
if (pageError || !page) {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
pagesToScan = [page];
|
||||
} else {
|
||||
// Scan all active pages for website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, website_id")
|
||||
.eq("website_id", websiteId)
|
||||
.eq("is_active", true);
|
||||
|
||||
if (pagesError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch pages" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
pagesToScan = pages || [];
|
||||
}
|
||||
|
||||
if (pagesToScan.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ message: "No active pages found to scan" },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create scans for all pages
|
||||
const scanPromises = pagesToScan.map(async (page) => {
|
||||
const { data: scan, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
page_id: page.id,
|
||||
status: "pending",
|
||||
trigger_type: triggerType,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to create scan for page ${page.id}:`, scanError);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trigger Lighthouse scan
|
||||
try {
|
||||
await triggerLighthouseScan(scan.id as string, page.url as string);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to trigger Lighthouse scan for ${page.url}:`,
|
||||
error,
|
||||
);
|
||||
// Mark scan as failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scan.id as string);
|
||||
}
|
||||
|
||||
return scan;
|
||||
});
|
||||
|
||||
const scans = await Promise.all(scanPromises);
|
||||
const successfulScans = scans.filter((scan) => scan !== null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Started ${successfulScans.length} scans`,
|
||||
scanIds: successfulScans.map((scan) => scan.id),
|
||||
totalPages: pagesToScan.length,
|
||||
successfulScans: successfulScans.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Analysis error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to start analysis: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerLighthouseScan(scanId: string, url: string) {
|
||||
// Update scan status to running
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "running",
|
||||
started_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
try {
|
||||
// Call Lighthouse service
|
||||
const lighthouseUrl =
|
||||
process.env.LIGHTHOUSE_SERVICE_URL || "http://localhost:5001";
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Lighthouse service responded with ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Save scan results
|
||||
await saveScanResults(scanId, result);
|
||||
|
||||
// Update scan status to completed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
} catch (error) {
|
||||
console.error(`Lighthouse scan failed for ${url}:`, error);
|
||||
|
||||
// Update scan status to failed
|
||||
await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.update({
|
||||
status: "failed",
|
||||
error_message: error instanceof Error ? error.message : "Unknown error",
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", scanId);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScanResults(scanId: string, lighthouseResult: any) {
|
||||
try {
|
||||
const { categories, audits, raw } = lighthouseResult;
|
||||
|
||||
// Save raw Lighthouse data
|
||||
await getSupabaseAdmin().from("scan_results").insert([
|
||||
{
|
||||
scan_id: scanId,
|
||||
raw_data: raw,
|
||||
performance_score: categories?.performance?.score
|
||||
? Math.round(categories.performance.score * 100)
|
||||
: null,
|
||||
seo_score: categories?.seo?.score
|
||||
? Math.round(categories.seo.score * 100)
|
||||
: null,
|
||||
accessibility_score: categories?.accessibility?.score
|
||||
? Math.round(categories.accessibility.score * 100)
|
||||
: null,
|
||||
best_practices_score: categories?.["best-practices"]?.score
|
||||
? Math.round(categories["best-practices"].score * 100)
|
||||
: null,
|
||||
first_contentful_paint:
|
||||
audits?.["first-contentful-paint"]?.numericValue || null,
|
||||
largest_contentful_paint:
|
||||
audits?.["largest-contentful-paint"]?.numericValue || null,
|
||||
cumulative_layout_shift:
|
||||
audits?.["cumulative-layout-shift"]?.numericValue || null,
|
||||
total_blocking_time:
|
||||
audits?.["total-blocking-time"]?.numericValue || null,
|
||||
speed_index: audits?.["speed-index"]?.numericValue || null,
|
||||
},
|
||||
]);
|
||||
|
||||
// Extract and save metric values
|
||||
const metricValues = [];
|
||||
|
||||
// Core Web Vitals
|
||||
if (audits?.["first-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "first_contentful_paint",
|
||||
value: audits["first-contentful-paint"].numericValue,
|
||||
score: audits["first-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["largest-contentful-paint"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "largest_contentful_paint",
|
||||
value: audits["largest-contentful-paint"].numericValue,
|
||||
score: audits["largest-contentful-paint"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["cumulative-layout-shift"]?.numericValue !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "cumulative_layout_shift",
|
||||
value: audits["cumulative-layout-shift"].numericValue,
|
||||
score: audits["cumulative-layout-shift"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (audits?.["total-blocking-time"]?.numericValue) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "total_blocking_time",
|
||||
value: audits["total-blocking-time"].numericValue,
|
||||
score: audits["total-blocking-time"].score,
|
||||
});
|
||||
}
|
||||
|
||||
// Category scores
|
||||
if (categories?.performance?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "performance_score",
|
||||
value: Math.round(categories.performance.score * 100),
|
||||
score: categories.performance.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.seo?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "seo_score",
|
||||
value: Math.round(categories.seo.score * 100),
|
||||
score: categories.seo.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.accessibility?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "accessibility_score",
|
||||
value: Math.round(categories.accessibility.score * 100),
|
||||
score: categories.accessibility.score,
|
||||
});
|
||||
}
|
||||
|
||||
if (categories?.["best-practices"]?.score !== undefined) {
|
||||
metricValues.push({
|
||||
scan_id: scanId,
|
||||
metric_key: "best_practices_score",
|
||||
value: Math.round(categories["best-practices"].score * 100),
|
||||
score: categories["best-practices"].score,
|
||||
});
|
||||
}
|
||||
|
||||
if (metricValues.length > 0) {
|
||||
await getSupabaseAdmin().from("metric_values").insert(metricValues);
|
||||
}
|
||||
|
||||
// Save resource analysis if available
|
||||
if (raw?.audits?.["resource-summary"]?.details?.items) {
|
||||
const resources = raw.audits["resource-summary"].details.items.map(
|
||||
(item: any) => ({
|
||||
scan_id: scanId,
|
||||
resource_type: item.resourceType || "other",
|
||||
count: item.requestCount || 0,
|
||||
size_bytes: item.size || 0,
|
||||
transfer_size_bytes: item.transferSize || 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await getSupabaseAdmin().from("resource_analysis").insert(resources);
|
||||
}
|
||||
|
||||
console.log(`Successfully saved scan results for scan ${scanId}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save scan results for scan ${scanId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Replace this with your actual database query
|
||||
const { data: competitors, error } = await supabase
|
||||
.from("competitor_metrics")
|
||||
.select("*");
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Transform the data to match the CompetitorData type
|
||||
const transformedData = {
|
||||
yourSite: {
|
||||
// Your site's data
|
||||
},
|
||||
competitors: competitors.map((competitor) => ({
|
||||
id: competitor.id,
|
||||
name: competitor.name,
|
||||
url: competitor.url,
|
||||
// Transform competitor data
|
||||
})),
|
||||
};
|
||||
|
||||
return NextResponse.json(transformedData);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch competitor data" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({ pages: [] });
|
||||
}
|
||||
|
||||
// Hole alle gefundenen Seiten für die Session
|
||||
const { data: pages } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("url")
|
||||
.eq("session_id", sessionId);
|
||||
|
||||
return NextResponse.json({ pages: pages?.map((p) => p.url) || [] });
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { NewCrawlerService } from "@/services/newCrawlerService";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Parse request body
|
||||
let websiteId;
|
||||
try {
|
||||
const body = await request.json();
|
||||
websiteId = body.websiteId;
|
||||
|
||||
// Normalize the websiteId to ensure consistent format
|
||||
websiteId = String(websiteId).trim().toLowerCase();
|
||||
console.log("Processing website ID:", websiteId);
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing request body:", parseError);
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!websiteId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch website details using admin client (bypasses RLS)
|
||||
const { data: websites, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url")
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (websiteError) {
|
||||
console.error("Website query error:", websiteError);
|
||||
return NextResponse.json(
|
||||
{ error: `Database error: ${websiteError.message}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if website exists
|
||||
if (!websites || websites.length === 0) {
|
||||
console.error("Website not found with ID:", websiteId);
|
||||
|
||||
// Try to find similar websites for debugging
|
||||
const { data: allWebsites } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, name, base_url");
|
||||
|
||||
console.log(`Found ${allWebsites?.length || 0} total websites`);
|
||||
|
||||
// Log available IDs for comparison
|
||||
const availableIds =
|
||||
allWebsites?.map((w) => String(w.id).toLowerCase()) || [];
|
||||
console.log("Available website IDs:", availableIds);
|
||||
|
||||
return NextResponse.json({
|
||||
error: "Website not found",
|
||||
debug: {
|
||||
requestedId: websiteId,
|
||||
availableIds: availableIds,
|
||||
totalWebsites: allWebsites?.length || 0
|
||||
}
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
// Website found, proceed with crawl
|
||||
const website = websites[0];
|
||||
console.log("Found website:", website.name, website.base_url);
|
||||
|
||||
// Create a new crawl session (using admin client)
|
||||
const { data: sessions, error: sessionError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.insert([
|
||||
{
|
||||
website_id: website.id, // Use the ID from the database
|
||||
status: "pending",
|
||||
start_url: website.base_url,
|
||||
total_urls: 0,
|
||||
processed_urls: 0,
|
||||
progress_percentage: 0,
|
||||
pages_discovered: 0,
|
||||
pages_processed: 0,
|
||||
},
|
||||
])
|
||||
.select();
|
||||
|
||||
if (sessionError) {
|
||||
console.error("Failed to create crawl session:", sessionError);
|
||||
console.error("Session error details:", {
|
||||
message: sessionError.message,
|
||||
details: sessionError.details,
|
||||
hint: sessionError.hint,
|
||||
code: sessionError.code
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to create crawl session",
|
||||
details: sessionError.message,
|
||||
code: sessionError.code
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!sessions || sessions.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Session was created but not returned" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const session = sessions[0] as { id: string };
|
||||
console.log("Created crawl session:", session.id);
|
||||
|
||||
// Start crawler in background
|
||||
const crawler = new NewCrawlerService(String(website.id), String(session.id));
|
||||
crawler.startCrawl().catch((err) => {
|
||||
console.error("Crawler error:", err);
|
||||
});
|
||||
|
||||
// Return successful response
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Crawl started",
|
||||
sessionId: session.id,
|
||||
});
|
||||
} catch (error) {
|
||||
// Catch-all error handler
|
||||
console.error("Crawl initialization error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start crawl: " + (error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { scanScheduler } from "@/services/scanScheduler";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const mode = url.searchParams.get("mode") || "all"; // "scheduled", "change_detection", "all"
|
||||
const organizationId = url.searchParams.get("organizationId"); // Optional: limit to specific org
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_start', mode, timestamp: new Date().toISOString() }));
|
||||
|
||||
const results = {
|
||||
scheduledScans: 0,
|
||||
changeDetectionScans: 0,
|
||||
errors: [] as string[],
|
||||
startTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Process scheduled scans
|
||||
if (mode === "scheduled" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_scheduled_scans', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processScheduledScans();
|
||||
|
||||
// Get count of processed scans
|
||||
const scheduledScans = await scanScheduler.getScheduledScans();
|
||||
results.scheduledScans = scheduledScans.length;
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scheduled_scans_processed', count: results.scheduledScans, timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing scheduled scans: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Process change detection
|
||||
if (mode === "change_detection" || mode === "all") {
|
||||
try {
|
||||
console.info(JSON.stringify({ level: 'info', event: 'processing_change_detection', timestamp: new Date().toISOString() }));
|
||||
await scanScheduler.processChangeDetection();
|
||||
|
||||
// Note: Change detection count is harder to track since it's based on actual changes
|
||||
// We'll just indicate it was processed
|
||||
results.changeDetectionScans = -1; // -1 indicates processed but count unknown
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'change_detection_processed', timestamp: new Date().toISOString() }));
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing change detection: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
results.errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Get overall statistics
|
||||
const stats = await getScanStatistics(organizationId ?? undefined);
|
||||
|
||||
const response = {
|
||||
success: results.errors.length === 0,
|
||||
message: `Automatic scan process completed - ${results.scheduledScans} scheduled scans, change detection processed`,
|
||||
results,
|
||||
statistics: stats,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'scan_process_completed', success: response.success, timestamp: new Date().toISOString() }));
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
const errorMsg = `Critical error in automatic scan process: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan statistics for monitoring
|
||||
*/
|
||||
async function getScanStatistics(organizationId?: string) {
|
||||
try {
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('scans')
|
||||
.select('id, status, created_at, triggered_by');
|
||||
|
||||
if (organizationId) {
|
||||
const { data: websitesForOrg, error: orgErr } = await supabase
|
||||
.from('websites')
|
||||
.select('id')
|
||||
.eq('organization_id', organizationId);
|
||||
if (orgErr) {
|
||||
throw orgErr;
|
||||
}
|
||||
const websiteIds = (websitesForOrg || []).map((w: any) => w.id);
|
||||
if (websiteIds.length === 0) {
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
query = query.in('website_id', websiteIds as any[]);
|
||||
}
|
||||
|
||||
// Get today's scans
|
||||
const { data: todayScans } = await query
|
||||
.gte('created_at', startOfDay.toISOString());
|
||||
|
||||
// Get this month's scans
|
||||
const { data: monthScans } = await query
|
||||
.gte('created_at', startOfMonth.toISOString());
|
||||
|
||||
// Get scans by status
|
||||
const { data: statusCounts } = await query
|
||||
.select('status') as unknown as { data: Array<{ status: string }> };
|
||||
|
||||
const statusBreakdown = (statusCounts?.reduce((acc: Record<string, number>, scan: { status: string }) => {
|
||||
const key = String(scan.status || 'unknown');
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
// Get scans by trigger type
|
||||
const { data: triggerCounts } = await query
|
||||
.select('triggered_by') as unknown as { data: Array<{ triggered_by: string | null }> };
|
||||
|
||||
const triggerBreakdown = (triggerCounts?.reduce((acc: Record<string, number>, scan: { triggered_by: string | null }) => {
|
||||
const trigger = String(scan.triggered_by || 'unknown');
|
||||
acc[trigger] = (acc[trigger] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)) || {};
|
||||
|
||||
return {
|
||||
today: {
|
||||
total: todayScans?.length || 0,
|
||||
byStatus: statusBreakdown,
|
||||
byTrigger: triggerBreakdown,
|
||||
},
|
||||
thisMonth: {
|
||||
total: monthScans?.length || 0,
|
||||
},
|
||||
last24Hours: {
|
||||
total: todayScans?.length || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Error getting scan statistics', error);
|
||||
return {
|
||||
today: { total: 0, byStatus: {}, byTrigger: {} },
|
||||
thisMonth: { total: 0 },
|
||||
last24Hours: { total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual scan trigger endpoint
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, pageId, deviceType = 'desktop', categories, priority = 'medium' } = body;
|
||||
|
||||
if (!websiteId || !pageId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and Page ID are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.info(JSON.stringify({ level: 'info', event: 'manual_scan_triggered', websiteId, pageId, timestamp: new Date().toISOString() }));
|
||||
|
||||
// Check subscription limits
|
||||
const { data: website } = await (await import("@/lib/admin")).getSupabaseAdmin()
|
||||
.from('websites')
|
||||
.select('organization_id')
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (!website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { canScan, limits, currentUsage } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Subscription limit exceeded",
|
||||
limits,
|
||||
currentUsage,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform the scan
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: deviceType as 'desktop' | 'mobile',
|
||||
categories: categories || ['performance', 'accessibility', 'seo', 'best_practices'],
|
||||
priority: priority as 'low' | 'medium' | 'high',
|
||||
triggeredBy: 'manual' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Scan completed successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error in manual scan: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await params;
|
||||
|
||||
// Get crawl queue items
|
||||
const { data: queueItems, error: queueError } = await getSupabaseAdmin()
|
||||
.from("crawl_queue")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (queueError) throw queueError;
|
||||
|
||||
// Get crawl sessions
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (sessionsError) throw sessionsError;
|
||||
|
||||
// Get pages discovered
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("id, url, title, is_active, depth, created_at, metadata")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (pagesError) throw pagesError;
|
||||
|
||||
// Get website info
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("*")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError) throw websiteError;
|
||||
|
||||
// Statistics
|
||||
const queueStats = {
|
||||
total: queueItems?.length || 0,
|
||||
pending: queueItems?.filter(item => item.status === 'pending').length || 0,
|
||||
processing: queueItems?.filter(item => item.status === 'processing').length || 0,
|
||||
completed: queueItems?.filter(item => item.status === 'completed').length || 0,
|
||||
failed: queueItems?.filter(item => item.status === 'failed').length || 0,
|
||||
skipped: queueItems?.filter(item => item.status === 'skipped').length || 0,
|
||||
};
|
||||
|
||||
const sessionStats = {
|
||||
total: sessions?.length || 0,
|
||||
running: sessions?.filter(s => s.status === 'running').length || 0,
|
||||
completed: sessions?.filter(s => s.status === 'completed').length || 0,
|
||||
failed: sessions?.filter(s => s.status === 'failed').length || 0,
|
||||
};
|
||||
|
||||
const pageStats = {
|
||||
total: pages?.length || 0,
|
||||
active: pages?.filter(p => p.is_active).length || 0,
|
||||
inactive: pages?.filter(p => !p.is_active).length || 0,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
website,
|
||||
queueStats,
|
||||
sessionStats,
|
||||
pageStats,
|
||||
queueItems: queueItems?.slice(0, 20), // Last 20 queue items
|
||||
sessions: sessions?.slice(0, 5), // Last 5 sessions
|
||||
pages: pages?.slice(0, 20), // Last 20 pages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching crawl debug info:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch crawl debug info" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ websiteId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { websiteId } = await context.params;
|
||||
|
||||
// Get crawl sessions for this website
|
||||
const { data: sessions, error: sessionsError } = await getSupabaseAdmin()
|
||||
.from("crawl_sessions")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
// Get all pages for this website
|
||||
const { data: pages, error: pagesError } = await getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
return NextResponse.json({
|
||||
sessions,
|
||||
sessionsError,
|
||||
pages,
|
||||
pagesError,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
// In-memory rate limiter (for demo/dev only; use Redis for production)
|
||||
const rateLimit: Record<string, { count: number; last: number }> = {};
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for backend
|
||||
);
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const ip = req.headers.get('x-forwarded-for') || '';
|
||||
const now = Date.now();
|
||||
|
||||
// Rate limiting
|
||||
if (!rateLimit[ip]) rateLimit[ip] = { count: 0, last: now };
|
||||
if (now - rateLimit[ip].last > WINDOW_MS) {
|
||||
rateLimit[ip] = { count: 0, last: now };
|
||||
}
|
||||
rateLimit[ip].count += 1;
|
||||
if (rateLimit[ip].count > MAX_ATTEMPTS) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests' }), {
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let email: string | undefined;
|
||||
try {
|
||||
const body = await req.json();
|
||||
email = body.email;
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (!email || typeof email !== 'string') {
|
||||
return new Response(JSON.stringify({ error: 'Invalid email' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Debug log incoming email
|
||||
console.log('API /api/email-exists: email =', email);
|
||||
|
||||
const { data, error } = await supabase.rpc('email_exists', { email_to_check: email });
|
||||
|
||||
// Debug log Supabase response
|
||||
console.log('API /api/email-exists: supabase.rpc response =', { data, error });
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: 'Server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ exists: data === true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { websiteId } = await request.json();
|
||||
|
||||
// Create a new scan
|
||||
const { data: scan, error: scanError } = await supabase
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (scanError) throw scanError;
|
||||
|
||||
// Trigger the analysis process
|
||||
const response = await fetch("/api/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
websiteId,
|
||||
scanId: scan.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to start analysis");
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, scanId: scan.id });
|
||||
} catch (error) {
|
||||
console.error("Monitor start error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start monitoring" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({ error: "Organization ID and User ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get all members of the organization
|
||||
const { data: members, error: membersError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (membersError) {
|
||||
console.error("Error fetching members:", membersError);
|
||||
return NextResponse.json({ error: "Failed to fetch members" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ members });
|
||||
} catch (error) {
|
||||
console.error("Error in members GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { organizationId, email, role, invitedBy } = await request.json();
|
||||
|
||||
if (!organizationId || !email || !role || !invitedBy) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID, email, role, and inviter ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify inviter has permission (must be owner or admin)
|
||||
const { data: inviter, error: inviterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", invitedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (inviterError || !inviter) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (inviter.role !== "owner" && inviter.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can invite members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if user already exists in the system
|
||||
const { data: existingUsers, error: userCheckError } = await getSupabaseAdmin()
|
||||
.auth.admin.listUsers();
|
||||
|
||||
if (userCheckError) {
|
||||
console.error("Error checking existing users:", userCheckError);
|
||||
return NextResponse.json({ error: "Failed to check existing users" }, { status: 500 });
|
||||
}
|
||||
|
||||
const existingUser = existingUsers.users.find(u => u.email === email);
|
||||
|
||||
if (existingUser) {
|
||||
// Check if user is already in an organization
|
||||
const { data: userRecord } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
if (userRecord?.organization_id) {
|
||||
if (userRecord.organization_id === organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of this organization"
|
||||
}, { status: 400 });
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
error: "User is already a member of another organization"
|
||||
}, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Add existing user to organization
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: organizationId, role })
|
||||
.eq("id", existingUser.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error adding existing user to organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to add user to organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get updated user data
|
||||
const { data: updatedUser } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("id, name, email, role, created_at")
|
||||
.eq("id", existingUser.id)
|
||||
.single();
|
||||
|
||||
return NextResponse.json({
|
||||
member: updatedUser,
|
||||
message: "Existing user added to organization"
|
||||
});
|
||||
} else {
|
||||
// Create invitation record for new user
|
||||
// Note: In a real app, you'd send an email invitation here
|
||||
// For now, we'll just create a placeholder record
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Invitation would be sent to new user",
|
||||
action: "invitation_sent"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in members POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { memberId, role, updatedBy, organizationId } = await request.json();
|
||||
|
||||
if (!memberId || !role || !updatedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, role, updater ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify updater has permission (must be owner)
|
||||
const { data: updater, error: updaterError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", updatedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (updaterError || !updater) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (updater.role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners can update member roles"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow changing the role of the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner" && role !== "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot change the role of the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Update member role
|
||||
const { data: updatedMember, error: updateError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ role })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId)
|
||||
.select("id, name, email, role, created_at")
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating member role:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update member role" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ member: updatedMember });
|
||||
} catch (error) {
|
||||
console.error("Error in members PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const memberId = url.searchParams.get("memberId");
|
||||
const removedBy = url.searchParams.get("removedBy");
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
|
||||
if (!memberId || !removedBy || !organizationId) {
|
||||
return NextResponse.json({
|
||||
error: "Member ID, remover ID, and organization ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify remover has permission (must be owner or admin)
|
||||
const { data: remover, error: removerError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", removedBy)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (removerError || !remover) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (remover.role !== "owner" && remover.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can remove members"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow removing the organization owner
|
||||
const { data: targetMember } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("role")
|
||||
.eq("id", memberId)
|
||||
.single();
|
||||
|
||||
if (targetMember?.role === "owner") {
|
||||
return NextResponse.json({
|
||||
error: "Cannot remove the organization owner"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Remove member from organization (set organization_id to null)
|
||||
const { error: removeError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.update({ organization_id: null, role: "member" })
|
||||
.eq("id", memberId)
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (removeError) {
|
||||
console.error("Error removing member:", removeError);
|
||||
return NextResponse.json({ error: "Failed to remove member" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in members DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { userId, name } = await request.json();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create organization
|
||||
const { data: org, error: orgError } = await supabase
|
||||
.from("organizations")
|
||||
.insert([
|
||||
{
|
||||
name: name || "My Organization",
|
||||
subscription_tier: "free",
|
||||
subscription_status: "active",
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (orgError) {
|
||||
throw orgError;
|
||||
}
|
||||
|
||||
// Update user with organization ID
|
||||
const { error: userError } = await supabase
|
||||
.from("users")
|
||||
.update({ organization_id: org.id })
|
||||
.eq("id", userId);
|
||||
|
||||
if (userError) {
|
||||
throw userError;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
organization: org,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Organization creation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create organization" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get organizations where user is a member
|
||||
const { data: userOrgs, error: userOrgError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select(`
|
||||
organization_id,
|
||||
role,
|
||||
organizations (
|
||||
id,
|
||||
name,
|
||||
subscription_tier,
|
||||
subscription_status,
|
||||
created_at
|
||||
)
|
||||
`)
|
||||
.eq("id", userId);
|
||||
|
||||
if (userOrgError) {
|
||||
console.error("Error fetching user organizations:", userOrgError);
|
||||
return NextResponse.json({ error: "Failed to fetch organizations" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get organization stats
|
||||
const orgIds = userOrgs?.map(u => u.organization_id).filter(Boolean) || [];
|
||||
|
||||
if (orgIds.length === 0) {
|
||||
return NextResponse.json({ organizations: [] });
|
||||
}
|
||||
|
||||
const [membersData, websitesData] = await Promise.all([
|
||||
// Get member counts
|
||||
getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds),
|
||||
|
||||
// Get website counts
|
||||
getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.in("organization_id", orgIds)
|
||||
]);
|
||||
|
||||
const memberCounts = (membersData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, member) => {
|
||||
const key = String(member.organization_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
const websiteCounts = (websitesData.data as Array<{ organization_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, website) => {
|
||||
const key = String(website.organization_id);
|
||||
acc[key] = (acc[key] || 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) || [];
|
||||
|
||||
return NextResponse.json({ organizations: orgsWithStats });
|
||||
} catch (error) {
|
||||
console.error("Error in organization GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, userId } = await request.json();
|
||||
|
||||
if (!id || !name || !userId) {
|
||||
return NextResponse.json({ error: "ID, name, and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to update this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can update organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update organization
|
||||
const { data: org, error: updateError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.update({ name })
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating organization:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ organization: org });
|
||||
} catch (error) {
|
||||
console.error("Error in organization PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({ error: "ID and user ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this organization
|
||||
const { data: userOrg, error: permissionError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", id)
|
||||
.single();
|
||||
|
||||
if (permissionError || !userOrg) {
|
||||
return NextResponse.json({ error: "Organization not found or access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner") {
|
||||
return NextResponse.json({ error: "Only organization owners can delete organizations" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete organization (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("organizations")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting organization:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete organization" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in organization DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url, deviceType = 'desktop' } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "URL is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Test the Lighthouse scanner directly
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/lighthouse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lighthouse service responded with ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Lighthouse test error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Lighthouse test failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test the scanner worker health
|
||||
const lighthouseUrl = process.env.LIGHTHOUSE_SERVICE_URL || 'http://localhost:5001';
|
||||
const response = await fetch(`${lighthouseUrl}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Scanner worker health check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const health = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scannerWorker: health,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Health check failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Test basic database connection
|
||||
const { data: websites, error: websitesError } = await supabase
|
||||
.from('websites')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test scans table connection
|
||||
const { data: scans, error: scansError } = await supabase
|
||||
.from('scans')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
// Test users table connection
|
||||
const { data: users, error: usersError } = await supabase
|
||||
.from('users')
|
||||
.select('count')
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
database: {
|
||||
websites: {
|
||||
connected: !websitesError,
|
||||
error: websitesError?.message || null
|
||||
},
|
||||
scans: {
|
||||
connected: !scansError,
|
||||
error: scansError?.message || null
|
||||
},
|
||||
users: {
|
||||
connected: !usersError,
|
||||
error: usersError?.message || null
|
||||
}
|
||||
},
|
||||
message: "Database connection test completed"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database test error:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
message: "Database connection test failed"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export async function GET() {
|
||||
// Check auth state in API route
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
console.log("API route auth check:", {
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
authError: authError?.message,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
isAuthenticated: !!user,
|
||||
userId: user?.id || null,
|
||||
authError: authError?.message || null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: "URL is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
let validUrl: URL;
|
||||
try {
|
||||
validUrl = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!['http:', 'https:'].includes(validUrl.protocol)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only HTTP and HTTPS URLs are supported" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the website with a timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'CloudLense Website Validator/1.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: `Website returned ${response.status} ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get content type
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if (!contentType.includes('text/html')) {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'URL does not point to an HTML page',
|
||||
});
|
||||
}
|
||||
|
||||
// Parse HTML to extract metadata
|
||||
const html = await response.text();
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : '';
|
||||
|
||||
// Extract meta description
|
||||
const descriptionMatch = html.match(/<meta[^>]+name=['"]description['"][^>]+content=['"]([^'"]*)['"]/i);
|
||||
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
|
||||
|
||||
// Try to get favicon
|
||||
const faviconMatch = html.match(/<link[^>]+rel=['"](?:icon|shortcut icon)['"][^>]+href=['"]([^'"]*)['"]/i);
|
||||
let favicon = faviconMatch ? faviconMatch[1] : '/favicon.ico';
|
||||
|
||||
// Convert relative favicon URL to absolute
|
||||
if (favicon && !favicon.startsWith('http')) {
|
||||
favicon = new URL(favicon, url).href;
|
||||
}
|
||||
|
||||
// Validate favicon exists
|
||||
let validFavicon: string | undefined;
|
||||
try {
|
||||
const faviconResponse = await fetch(favicon, {
|
||||
method: 'HEAD',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (faviconResponse.ok) {
|
||||
validFavicon = favicon;
|
||||
}
|
||||
} catch {
|
||||
// If favicon fails, try the Google favicon service
|
||||
validFavicon = `https://www.google.com/s2/favicons?domain=${validUrl.hostname}&sz=32`;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: true,
|
||||
title: title || validUrl.hostname,
|
||||
description: description || '',
|
||||
favicon: validFavicon,
|
||||
hostname: validUrl.hostname,
|
||||
protocol: validUrl.protocol,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Website took too long to respond (timeout)',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
isValid: false,
|
||||
error: 'Unable to connect to website. Please check the URL and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Website validation error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { lighthouseScanner } from "@/services/lighthouseScanner";
|
||||
import { logError } from "@/utils/errorUtils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { websiteId, url, changeType, contentHash, metadata } = body;
|
||||
|
||||
if (!websiteId || !url) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website ID and URL are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Structured log for production visibility
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'webhook_website_change_received',
|
||||
websiteId,
|
||||
changeType: changeType || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Verify the webhook signature if needed
|
||||
const signature = request.headers.get('x-webhook-signature');
|
||||
if (process.env.WEBHOOK_SECRET && signature) {
|
||||
// Add webhook signature verification here if needed
|
||||
// const isValid = verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET);
|
||||
// if (!isValid) {
|
||||
// return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
|
||||
// }
|
||||
}
|
||||
|
||||
// Get website details
|
||||
const { getSupabaseAdmin } = await import("@/lib/admin");
|
||||
const supabase = getSupabaseAdmin();
|
||||
|
||||
const { data: website, error: websiteError } = await supabase
|
||||
.from('websites')
|
||||
.select(`
|
||||
id,
|
||||
organization_id,
|
||||
organizations!inner (
|
||||
subscription_tier
|
||||
)
|
||||
`)
|
||||
.eq('id', websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json(
|
||||
{ error: "Website not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check subscription limits
|
||||
const { canScan, limits } = await lighthouseScanner.checkSubscriptionLimits(
|
||||
String(website.organization_id)
|
||||
);
|
||||
|
||||
if (!canScan) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'subscription_limit_exceeded',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Subscription limit exceeded - scan skipped",
|
||||
limits,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if change detection is enabled for this subscription
|
||||
const subscriptionLimits = getSubscriptionLimits(String((website as any).organizations?.subscription_tier || 'free'));
|
||||
if (!subscriptionLimits.changeDetectionEnabled) {
|
||||
console.warn(JSON.stringify({
|
||||
level: 'warn',
|
||||
event: 'change_detection_disabled',
|
||||
tier: String((website as any).organizations?.subscription_tier || 'unknown'),
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: "Change detection not available for this subscription tier",
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create the page record
|
||||
let pageId: string;
|
||||
const { data: existingPage } = await supabase
|
||||
.from('pages')
|
||||
.select('id, content_hash')
|
||||
.eq('website_id', websiteId)
|
||||
.eq('url', url)
|
||||
.single();
|
||||
|
||||
if (existingPage) {
|
||||
pageId = String((existingPage as any).id);
|
||||
|
||||
// Update the page with new content hash
|
||||
await supabase
|
||||
.from('pages')
|
||||
.update({
|
||||
content_hash: contentHash,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.eq('id', pageId);
|
||||
} else {
|
||||
// Create new page record
|
||||
const { data: newPage, error: createError } = await supabase
|
||||
.from('pages')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
url,
|
||||
path: new URL(url).pathname,
|
||||
content_hash: contentHash,
|
||||
title: metadata?.title || 'Unknown Page',
|
||||
is_active: true,
|
||||
metadata: metadata || {},
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (createError || !newPage) {
|
||||
throw new Error(`Failed to create page record: ${createError?.message}`);
|
||||
}
|
||||
|
||||
pageId = String((newPage as any).id);
|
||||
}
|
||||
|
||||
// Trigger a high-priority scan due to changes
|
||||
const scanConfig = {
|
||||
websiteId,
|
||||
pageId,
|
||||
deviceType: 'desktop' as const,
|
||||
categories: ['performance', 'accessibility', 'seo', 'best_practices'] as (
|
||||
'performance' | 'accessibility' | 'seo' | 'best_practices'
|
||||
)[],
|
||||
priority: 'high' as const,
|
||||
triggeredBy: 'change_detection' as const,
|
||||
};
|
||||
|
||||
const result = await lighthouseScanner.performScan(scanConfig);
|
||||
|
||||
// Log the change detection
|
||||
await supabase
|
||||
.from('audit_logs')
|
||||
.insert({
|
||||
website_id: websiteId,
|
||||
action: 'change_detected',
|
||||
entity_type: 'page',
|
||||
entity_id: pageId,
|
||||
changes: {
|
||||
change_type: changeType || 'content_update',
|
||||
url,
|
||||
content_hash: contentHash,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.info(JSON.stringify({
|
||||
level: 'info',
|
||||
event: 'change_detection_scan_completed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
scanId: result.scanId,
|
||||
message: "Change detection scan triggered successfully",
|
||||
metrics: result.metrics,
|
||||
});
|
||||
} else {
|
||||
console.error(JSON.stringify({
|
||||
level: 'error',
|
||||
event: 'change_detection_scan_failed',
|
||||
websiteId,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: result.error,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Error processing website change webhook: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
logError(errorMsg, error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription limits based on tier
|
||||
*/
|
||||
function getSubscriptionLimits(tier: string) {
|
||||
switch (tier) {
|
||||
case 'free':
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
case 'starter':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'professional':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
case 'enterprise':
|
||||
return {
|
||||
changeDetectionEnabled: true,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
changeDetectionEnabled: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const userId = url.searchParams.get("userId");
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has access to this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get scan configurations
|
||||
const { data: scanConfigs, error: scanError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
// Get alert configurations
|
||||
const { data: alertConfigs, error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.select("*")
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
if (scanError || alertError) {
|
||||
console.error("Error fetching configurations:", { scanError, alertError });
|
||||
}
|
||||
|
||||
// Structure the response
|
||||
const crawl = (website as any).crawl_settings || {};
|
||||
const settings = {
|
||||
scan: {
|
||||
scanInterval: crawl.crawl_frequency === "hourly" ? 60 :
|
||||
crawl.crawl_frequency === "daily" ? 1440 : 60,
|
||||
maxPages: crawl.max_pages || 100,
|
||||
maxDepth: crawl.max_depth || 3,
|
||||
userAgent: crawl.user_agent || "",
|
||||
excludePatterns: crawl.exclude_patterns || ["/admin/*", "/api/*"],
|
||||
includePatterns: crawl.include_patterns || ["/*"],
|
||||
respectRobotsTxt: crawl.respect_robots_txt !== false,
|
||||
followRedirects: crawl.follow_redirects !== false,
|
||||
maxConcurrentRequests: crawl.max_concurrent_requests || 3,
|
||||
},
|
||||
alerts: {
|
||||
performanceThreshold: 90,
|
||||
seoThreshold: 90,
|
||||
accessibilityThreshold: 90,
|
||||
uptimeThreshold: 99,
|
||||
notificationEmail: "",
|
||||
slackWebhook: "",
|
||||
enableEmailAlerts: true,
|
||||
enableSlackAlerts: false,
|
||||
alertFrequency: "immediate" as const,
|
||||
},
|
||||
scanConfigurations: scanConfigs || [],
|
||||
alertConfigurations: alertConfigs || [],
|
||||
};
|
||||
|
||||
// Override with actual alert settings if they exist
|
||||
if (alertConfigs && alertConfigs.length > 0) {
|
||||
alertConfigs.forEach((config: any) => {
|
||||
switch (config.metric) {
|
||||
case "performance":
|
||||
settings.alerts.performanceThreshold = Number(config.threshold) || settings.alerts.performanceThreshold;
|
||||
break;
|
||||
case "seo":
|
||||
settings.alerts.seoThreshold = Number(config.threshold) || settings.alerts.seoThreshold;
|
||||
break;
|
||||
case "accessibility":
|
||||
settings.alerts.accessibilityThreshold = Number(config.threshold) || settings.alerts.accessibilityThreshold;
|
||||
break;
|
||||
case "uptime":
|
||||
settings.alerts.uptimeThreshold = Number(config.threshold) || settings.alerts.uptimeThreshold;
|
||||
break;
|
||||
}
|
||||
settings.alerts.enableEmailAlerts = config.notification_channels?.includes("email") || false;
|
||||
settings.alerts.enableSlackAlerts = config.notification_channels?.includes("slack") || false;
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
console.error("Error in website settings GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId, settingsType, settings } = await request.json();
|
||||
const { id: websiteId } = await context.params;
|
||||
|
||||
if (!userId || !websiteId || !settingsType) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID, user ID, and settings type are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, crawl_settings")
|
||||
.eq("id", websiteId)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (settingsType === "scan") {
|
||||
// Update crawl settings
|
||||
const updatedCrawlSettings = {
|
||||
...(website as any).crawl_settings || {},
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
user_agent: settings.userAgent,
|
||||
exclude_patterns: settings.excludePatterns,
|
||||
include_patterns: settings.includePatterns,
|
||||
respect_robots_txt: settings.respectRobotsTxt,
|
||||
follow_redirects: settings.followRedirects,
|
||||
max_concurrent_requests: settings.maxConcurrentRequests,
|
||||
crawl_frequency: settings.scanInterval === 60 ? "hourly" :
|
||||
settings.scanInterval === 1440 ? "daily" : "hourly",
|
||||
};
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ crawl_settings: updatedCrawlSettings })
|
||||
.eq("id", websiteId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating scan settings:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update scan settings" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Update or create scan configurations
|
||||
const { error: scanConfigError } = await getSupabaseAdmin()
|
||||
.from("scan_configurations")
|
||||
.upsert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
category: "performance",
|
||||
interval_minutes: settings.scanInterval,
|
||||
is_active: true,
|
||||
priority: 1,
|
||||
settings: {
|
||||
lighthouse: true,
|
||||
uptime: true,
|
||||
max_pages: settings.maxPages,
|
||||
max_depth: settings.maxDepth,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (scanConfigError) {
|
||||
console.error("Error updating scan configuration:", scanConfigError);
|
||||
}
|
||||
|
||||
} else if (settingsType === "alerts") {
|
||||
// Update alert configurations
|
||||
const alertTypes = [
|
||||
{ metric: "performance", threshold: settings.performanceThreshold },
|
||||
{ metric: "seo", threshold: settings.seoThreshold },
|
||||
{ metric: "accessibility", threshold: settings.accessibilityThreshold },
|
||||
{ metric: "uptime", threshold: settings.uptimeThreshold },
|
||||
];
|
||||
|
||||
const notificationChannels: string[] = [];
|
||||
if (settings.enableEmailAlerts) notificationChannels.push("email");
|
||||
if (settings.enableSlackAlerts) notificationChannels.push("slack");
|
||||
|
||||
const alertConfigs = alertTypes.map(alert => ({
|
||||
website_id: websiteId,
|
||||
metric: alert.metric,
|
||||
threshold: alert.threshold,
|
||||
comparison: "less_than",
|
||||
notification_channels: notificationChannels,
|
||||
is_active: true,
|
||||
alert_frequency: settings.alertFrequency,
|
||||
email_address: settings.notificationEmail || null,
|
||||
slack_webhook: settings.slackWebhook || null,
|
||||
}));
|
||||
|
||||
// Delete existing configurations and insert new ones
|
||||
await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.delete()
|
||||
.eq("website_id", websiteId);
|
||||
|
||||
const { error: alertError } = await getSupabaseAdmin()
|
||||
.from("alert_configurations")
|
||||
.insert(alertConfigs);
|
||||
|
||||
if (alertError) {
|
||||
console.error("Error updating alert configurations:", alertError);
|
||||
return NextResponse.json({ error: "Failed to update alert settings" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `${settingsType} settings updated successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in website settings PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { action, websiteIds, userId, updates } = await request.json();
|
||||
|
||||
if (!action || !websiteIds || !Array.isArray(websiteIds) || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Action, website IDs array, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to all websites
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("id, organization_id, name")
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (websitesError || !websites) {
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Check if all websites belong to user's organization
|
||||
const { data: userOrg, error: userError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (userError || !userOrg) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const unauthorizedWebsites = websites.filter(w => w.organization_id !== userOrg.organization_id);
|
||||
if (unauthorizedWebsites.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: "Access denied to some websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
let results: any[] = [];
|
||||
|
||||
switch (action) {
|
||||
case "activate":
|
||||
const { error: activateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: true })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (activateError) {
|
||||
return NextResponse.json({ error: "Failed to activate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "activated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "deactivate":
|
||||
const { error: deactivateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update({ is_active: false })
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deactivateError) {
|
||||
return NextResponse.json({ error: "Failed to deactivate websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deactivated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
// Only owners and admins can delete websites
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: "Failed to delete websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "deleted"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "update":
|
||||
if (!updates || typeof updates !== "object") {
|
||||
return NextResponse.json({
|
||||
error: "Updates object is required for update action"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (updates.crawl_settings) updateData.crawl_settings = updates.crawl_settings;
|
||||
if (updates.is_active !== undefined) updateData.is_active = updates.is_active;
|
||||
|
||||
const { error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.in("id", websiteIds);
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: "Failed to update websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
results = websites.map(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: "updated"
|
||||
}));
|
||||
break;
|
||||
|
||||
case "scan":
|
||||
// Trigger scans for all websites
|
||||
const scanPromises = websiteIds.map(async (websiteId: string) => {
|
||||
try {
|
||||
// Insert scan request
|
||||
const { error: scanError } = await getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.insert([
|
||||
{
|
||||
website_id: websiteId,
|
||||
status: "pending",
|
||||
triggered_by: userId,
|
||||
scan_type: "manual",
|
||||
}
|
||||
]);
|
||||
|
||||
if (scanError) {
|
||||
console.error(`Failed to trigger scan for website ${websiteId}:`, scanError);
|
||||
return { id: websiteId, status: "scan_failed", error: scanError.message };
|
||||
}
|
||||
|
||||
return { id: websiteId, status: "scan_triggered" };
|
||||
} catch (error) {
|
||||
console.error(`Error triggering scan for website ${websiteId}:`, error);
|
||||
return { id: websiteId, status: "scan_failed" };
|
||||
}
|
||||
});
|
||||
|
||||
const scanResults = await Promise.all(scanPromises);
|
||||
results = websites.map(w => {
|
||||
const scanResult = scanResults.find(r => r.id === w.id);
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
status: scanResult?.status || "scan_failed",
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({
|
||||
error: "Invalid action. Supported actions: activate, deactivate, delete, update, scan"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
action,
|
||||
results,
|
||||
message: `Successfully ${action}d ${results.length} website(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error in websites bulk operation:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSupabaseAdmin } from "@/lib/admin";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
// Initialize Supabase with service role for admin operations
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const organizationId = url.searchParams.get("organizationId");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Organization ID and User ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has access to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get websites with their latest scan information
|
||||
const { data: websites, error: websitesError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select(`
|
||||
id,
|
||||
name,
|
||||
base_url,
|
||||
is_active,
|
||||
created_at,
|
||||
settings
|
||||
`)
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (websitesError) {
|
||||
console.error("Error fetching websites:", websitesError);
|
||||
return NextResponse.json({ error: "Failed to fetch websites" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Get additional stats for each website
|
||||
const websiteIds = websites?.map(w => w.id) || [];
|
||||
|
||||
if (websiteIds.length > 0) {
|
||||
const [pagesData, scansData] = await Promise.all([
|
||||
getSupabaseAdmin()
|
||||
.from("pages")
|
||||
.select("website_id")
|
||||
.in("website_id", websiteIds),
|
||||
|
||||
getSupabaseAdmin()
|
||||
.from("scans")
|
||||
.select("website_id, status, lighthouse_score")
|
||||
.in("website_id", websiteIds)
|
||||
.order("created_at", { ascending: false })
|
||||
]);
|
||||
|
||||
// Count pages per website
|
||||
const pagesCounts = (pagesData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, number>, page) => {
|
||||
const key = String(page.website_id);
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>) || {};
|
||||
|
||||
// Get latest scan per website
|
||||
const latestScans = (scansData.data as Array<{ website_id: string }> | null | undefined)?.reduce((acc: Record<string, any>, scan: any) => {
|
||||
const key = String(scan.website_id);
|
||||
if (!acc[key]) {
|
||||
acc[key] = scan;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, any>) || {};
|
||||
|
||||
// Add stats to websites
|
||||
const websitesWithStats = (websites as Array<any>).map((website: any) => ({
|
||||
...website,
|
||||
stats: {
|
||||
pagesCount: pagesCounts[String(website.id)] || 0,
|
||||
latestScan: latestScans[String(website.id)] || null,
|
||||
}
|
||||
}));
|
||||
|
||||
return NextResponse.json({ websites: websitesWithStats });
|
||||
}
|
||||
|
||||
return NextResponse.json({ websites: websites || [] });
|
||||
} catch (error) {
|
||||
console.error("Error in websites GET:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, base_url, organizationId, userId, crawl_settings } = await request.json();
|
||||
|
||||
if (!name || !base_url || !organizationId || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Name, URL, organization ID, and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify user has permission to add websites to this organization
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", organizationId)
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Normalize URL
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
normalizedUrl = normalizedUrl.replace(/\/+$/, "");
|
||||
|
||||
// Default crawl settings
|
||||
const defaultCrawlSettings = {
|
||||
max_depth: 3,
|
||||
max_pages: 100,
|
||||
exclude_patterns: ["/admin/*", "/api/*", "*.pdf", "*.jpg", "*.png"],
|
||||
include_patterns: ["/*"],
|
||||
respect_robots_txt: true,
|
||||
crawl_frequency: "daily",
|
||||
...crawl_settings
|
||||
};
|
||||
|
||||
// Create website
|
||||
const { data: website, error: createError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.insert([
|
||||
{
|
||||
name,
|
||||
base_url: normalizedUrl,
|
||||
organization_id: organizationId,
|
||||
is_active: true,
|
||||
crawl_settings: defaultCrawlSettings,
|
||||
scan_status: "pending"
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (createError) {
|
||||
console.error("Error creating website:", createError);
|
||||
return NextResponse.json({ error: "Failed to create website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website });
|
||||
} catch (error) {
|
||||
console.error("Error in websites POST:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, name, base_url, is_active, crawl_settings, userId } = await request.json();
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to update this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: any = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (base_url !== undefined) {
|
||||
let normalizedUrl = base_url;
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
updateData.base_url = normalizedUrl.replace(/\/+$/, "");
|
||||
}
|
||||
if (is_active !== undefined) updateData.is_active = is_active;
|
||||
if (crawl_settings !== undefined) updateData.crawl_settings = crawl_settings;
|
||||
|
||||
// Update website
|
||||
const { data: updatedWebsite, error: updateError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.update(updateData)
|
||||
.eq("id", id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (updateError) {
|
||||
console.error("Error updating website:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ website: updatedWebsite });
|
||||
} catch (error) {
|
||||
console.error("Error in websites PUT:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!id || !userId) {
|
||||
return NextResponse.json({
|
||||
error: "Website ID and user ID are required"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Get website to verify ownership
|
||||
const { data: website, error: websiteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.select("organization_id, name")
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (websiteError || !website) {
|
||||
return NextResponse.json({ error: "Website not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify user has permission to delete this website
|
||||
const { data: userOrg, error: accessError } = await getSupabaseAdmin()
|
||||
.from("users")
|
||||
.select("organization_id, role")
|
||||
.eq("id", userId)
|
||||
.eq("organization_id", String((website as any).organization_id))
|
||||
.single();
|
||||
|
||||
if (accessError || !userOrg) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (userOrg.role !== "owner" && userOrg.role !== "admin") {
|
||||
return NextResponse.json({
|
||||
error: "Only owners and admins can delete websites"
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete website (CASCADE should handle related records)
|
||||
const { error: deleteError } = await getSupabaseAdmin()
|
||||
.from("websites")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (deleteError) {
|
||||
console.error("Error deleting website:", deleteError);
|
||||
return NextResponse.json({ error: "Failed to delete website" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Website "${website.name}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in websites DELETE:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthCallback = async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(window.location.href);
|
||||
const token = searchParams.get("token");
|
||||
const type = searchParams.get("type");
|
||||
|
||||
if (token && type === "email_verification") {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash: token,
|
||||
type: "email",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Redirect to dashboard after successful verification
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during auth callback:", error);
|
||||
router.push("/auth?error=verification_failed");
|
||||
}
|
||||
};
|
||||
|
||||
handleAuthCallback();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-r from-blue-500 to-purple-500">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Verifying your email...
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Please wait while we complete the process.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FixAccount } from "@/components/auth/FixAccount";
|
||||
|
||||
export default function FixAccountPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<FixAccount />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { AuthForm } from "@/components/auth/AuthForm";
|
||||
import { Shield, ArrowLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
export default function AuthPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const source = searchParams.get("source");
|
||||
const email = searchParams.get("email");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (user) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [user, router]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02]" />
|
||||
|
||||
{mounted && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-0 -left-4 w-[500px] h-[500px] bg-gradient-to-br from-blue-400/20 to-indigo-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="absolute bottom-0 -right-4 w-[500px] h-[500px] bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="relative w-full max-w-lg mx-auto"
|
||||
>
|
||||
{/* Logo and name above the card */}
|
||||
<div className="absolute top-[-40px] left-1/2 transform -translate-x-1/2 flex items-center space-x-2">
|
||||
<Shield className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-2xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||
CloudLense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Glass card effect */}
|
||||
<div className="relative backdrop-blur-xl bg-white/80 rounded-2xl shadow-xl border border-white/20 overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-8 pt-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 cursor-pointer transition-transform transform hover:scale-110"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
Back to Home
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 mb-4"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{source === "hero" ? "Get Started" : "Welcome"}
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
{source === "hero"
|
||||
? "Set up your account in seconds"
|
||||
: "Sign in to continue to your dashboard"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
<div className="p-8">
|
||||
<AuthForm initialEmail={email} />
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
className="px-8 pb-8 text-center"
|
||||
>
|
||||
<p className="text-sm text-gray-500">
|
||||
By continuing, you agree to our{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-100 rounded-2xl -m-2"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-50 rounded-2xl -m-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { Button } from "@/components/ui/forms/Button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/forms/Form";
|
||||
import { Input } from "@/components/ui/forms/Input";
|
||||
import { Shield, Key, Loader2, Check, ArrowLeft } from "lucide-react";
|
||||
import { ErrorFeedback } from "@/components/ui/ErrorFeedback";
|
||||
|
||||
const resetPasswordSchema = z.object({
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
confirmPassword: z.string().min(6, "Password must be at least 6 characters"),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const form = useForm<z.infer<typeof resetPasswordSchema>>({
|
||||
resolver: zodResolver(resetPasswordSchema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// Check if we have the proper reset token in the URL
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const refreshToken = searchParams.get('refresh_token');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError("Invalid or expired reset link. Please request a new password reset.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the session with the tokens from the URL
|
||||
supabase.auth.setSession({
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, [searchParams]);
|
||||
|
||||
const handlePasswordReset = async (data: z.infer<typeof resetPasswordSchema>) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Redirect to dashboard after successful reset
|
||||
setTimeout(() => {
|
||||
router.push("/dashboard");
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("Failed to reset password. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-white p-4">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.02]" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1 }}
|
||||
className="absolute top-0 -left-4 w-[500px] h-[500px] bg-gradient-to-br from-blue-400/20 to-indigo-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.5 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="absolute bottom-0 -right-4 w-[500px] h-[500px] bg-gradient-to-br from-purple-400/20 to-pink-400/20 rounded-full blur-3xl"
|
||||
style={{ filter: "blur(120px)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content container */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
className="relative w-full max-w-lg mx-auto"
|
||||
>
|
||||
{/* Logo and name above the card */}
|
||||
<div className="absolute top-[-40px] left-1/2 transform -translate-x-1/2 flex items-center space-x-2">
|
||||
<Shield className="h-8 w-8 text-blue-600" />
|
||||
<span className="text-2xl font-semibold bg-clip-text text-transparent bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||
CloudLense
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Glass card effect */}
|
||||
<div className="relative backdrop-blur-xl bg-white/80 rounded-2xl shadow-xl border border-white/20 overflow-hidden">
|
||||
{/* Card header */}
|
||||
<div className="px-8 pt-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
className="flex items-center space-x-4 cursor-pointer transition-transform transform hover:scale-110"
|
||||
onClick={() => router.push("/auth")}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ArrowLeft className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-600">
|
||||
Back to Login
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
className="mt-6 mb-4"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Reset Your Password
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Enter your new password below
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Form section */}
|
||||
<div className="p-8">
|
||||
{success ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Check className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Password Reset Successfully</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Your password has been updated. Redirecting to dashboard...
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handlePasswordReset)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Enter new password"
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
||||
<Input
|
||||
{...field}
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
className="pl-10"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transition-all duration-300"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
"Update Password"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<ErrorFeedback
|
||||
title="Password Reset Failed"
|
||||
message={error}
|
||||
details="Please try requesting a new password reset link."
|
||||
severity="error"
|
||||
onDismiss={() => setError("")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-100 rounded-2xl -m-2"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.6 }}
|
||||
className="absolute -z-10 inset-0 border border-blue-50 rounded-2xl -m-4"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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're looking for doesn't exist or you don'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' 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' 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'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.129 0.042 264.695);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.129 0.042 264.695);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.984 0.003 247.858);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.279 0.041 260.031);
|
||||
--input: oklch(0.279 0.041 260.031);
|
||||
--ring: oklch(0.446 0.043 257.281);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(0.279 0.041 260.031);
|
||||
--sidebar-ring: oklch(0.446 0.043 257.281);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
50% {
|
||||
width: 70%;
|
||||
}
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-progress {
|
||||
animation: progress 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.grid-cols-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.max-w-7xl {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.p-8 {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.h-24 {
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.h-2 {
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-4 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.p-1 {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
|
||||
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
|
||||
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.8s ease forwards;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-grid-pattern {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
#e5e7eb 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Providers } from "./providers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Website Monitoring",
|
||||
description:
|
||||
"Analyze and optimize your website's performance, accessibility, and SEO",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>{children} </Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import HeroPage from "@/components/demo/HeroPage";
|
||||
|
||||
export default function Home() {
|
||||
return <HeroPage />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { ErrorBoundary } from "@/components/ui/feedback/ErrorBoundary";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user