From 6e41d9874d12c18aa1e6189c9f8ece27d61adb5a Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 23 Aug 2025 17:45:01 +0000 Subject: [PATCH] feat: Add Google Analytics integration and fix anonymous tracking - Add Google Analytics tracking (G-ZDZ26XYN2P) to frontend - Create comprehensive analytics utility with event tracking - Track URL submissions, analysis results, and user authentication - Add route tracking for SPA navigation - Fix CORS configuration to support both localhost and production - Fix home page tracking form to display results instead of auto-redirect - Add service management scripts for easier deployment - Update database migrations for enhanced analysis features Key Features: - Anonymous and authenticated user tracking - SSL/SEO/Security analysis event tracking - Error tracking for debugging - Page view tracking for SPA routes - Multi-origin CORS support for development and production --- apps/api/src/index.ts | 20 +- apps/api/src/routes/projects.routes.ts | 446 ++++++++++++++++++ apps/api/src/routes/tracking.routes.ts | 60 ++- .../src/services/redirect-tracker.service.ts | 53 ++- apps/web/index.html | 10 + apps/web/src/App.tsx | 4 + .../src/components/Analytics/RouteTracker.tsx | 20 + apps/web/src/contexts/AuthContext.tsx | 4 + apps/web/src/pages/DashboardPage.tsx | 234 ++++++++- apps/web/src/pages/HomePage.tsx | 24 +- apps/web/src/pages/LoginPage.tsx | 182 +++++-- apps/web/src/pages/RegisterPage.tsx | 317 +++++++++++-- apps/web/src/services/api.ts | 83 +++- apps/web/src/utils/analytics.ts | 110 +++++ .../migration.sql | 264 +++++++++++ .../prisma/migrations/migration_lock.toml | 3 + service-control.sh | 69 +++ start-services.sh | 68 +++ stop-services.sh | 28 ++ 19 files changed, 1904 insertions(+), 95 deletions(-) create mode 100644 apps/api/src/routes/projects.routes.ts create mode 100644 apps/web/src/components/Analytics/RouteTracker.tsx create mode 100644 apps/web/src/utils/analytics.ts create mode 100644 packages/database/prisma/migrations/20250819192511_init_user_management/migration.sql create mode 100644 packages/database/prisma/migrations/migration_lock.toml create mode 100755 service-control.sh create mode 100755 start-services.sh create mode 100755 stop-services.sh diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 09dde4aa..ccb0004c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -20,6 +20,7 @@ import trackingRoutes from './routes/tracking.routes'; import analysisRoutes from './routes/analysis.routes'; import exportRoutes from './routes/export.routes'; import bulkRoutes from './routes/bulk.routes'; +import projectsRoutes from './routes/projects.routes'; import docsRoutes from './routes/docs.routes'; import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware'; @@ -45,8 +46,23 @@ app.use(compression()); app.use(requestLogger({ redactionLevel: 'partial' })); // CORS middleware +const allowedOrigins = [ + 'http://localhost:3000', + 'https://urltrackertool.com', + process.env.CORS_ORIGIN +].filter(Boolean); + app.use(cors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + return callback(new Error('Not allowed by CORS')); + }, credentials: true, optionsSuccessStatus: 200 // Some legacy browsers (IE11, various SmartTVs) choke on 204 })); @@ -84,12 +100,14 @@ app.use('/v2/analyze', analysisRoutes); // Export routes (v2) app.use('/v2/export', exportRoutes); app.use('/v2/bulk', bulkRoutes); +app.use('/v2/projects', projectsRoutes); // Backward compatibility: keep /api/v2 routes as well app.use('/api/v2', trackingRoutes); app.use('/api/v2/analyze', analysisRoutes); app.use('/api/v2/export', exportRoutes); app.use('/api/v2/bulk', bulkRoutes); +app.use('/api/v2/projects', projectsRoutes); // Documentation routes app.use('/', docsRoutes); diff --git a/apps/api/src/routes/projects.routes.ts b/apps/api/src/routes/projects.routes.ts new file mode 100644 index 00000000..eca67990 --- /dev/null +++ b/apps/api/src/routes/projects.routes.ts @@ -0,0 +1,446 @@ +/** + * Project Management Routes for URL Tracker Tool V2 + * + * Handles project creation, management, and organization + */ + +import express from 'express'; +import { z } from 'zod'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware'; +import { PrismaClient } from '@prisma/client'; +import { logger } from '../lib/logger'; + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Validation schemas +const createProjectSchema = z.object({ + name: z.string().min(1, 'Project name is required').max(100, 'Project name too long'), + description: z.string().optional(), + settings: z.object({ + defaultMethod: z.enum(['GET', 'POST', 'HEAD']).default('GET'), + defaultTimeout: z.number().min(1000).max(30000).default(15000), + defaultMaxHops: z.number().min(1).max(20).default(10), + enableSSLAnalysis: z.boolean().default(true), + enableSEOAnalysis: z.boolean().default(true), + enableSecurityAnalysis: z.boolean().default(true), + }).optional(), +}); + +const updateProjectSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().optional(), + settings: z.object({ + defaultMethod: z.enum(['GET', 'POST', 'HEAD']).optional(), + defaultTimeout: z.number().min(1000).max(30000).optional(), + defaultMaxHops: z.number().min(1).max(20).optional(), + enableSSLAnalysis: z.boolean().optional(), + enableSEOAnalysis: z.boolean().optional(), + enableSecurityAnalysis: z.boolean().optional(), + }).optional(), +}); + +const projectParamsSchema = z.object({ + projectId: z.string().min(1), +}); + +/** + * GET /api/v2/projects + * Get user's projects + */ +router.get('/', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const user = req.user!; + const orgId = user.memberships[0]?.orgId; + + if (!orgId) { + return res.status(404).json({ + success: false, + error: 'No organization found', + message: 'User must belong to an organization to access projects' + }); + } + + const projects = await prisma.project.findMany({ + where: { + orgId: orgId, + }, + include: { + _count: { + select: { + checks: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + res.json({ + success: true, + data: { + projects: projects.map(project => ({ + id: project.id, + name: project.name, + description: (project.settingsJson as any)?.description || null, + settings: project.settingsJson, + trackingCount: project._count.checks, + createdAt: project.createdAt, + })), + }, + }); + + } catch (error) { + logger.error('Failed to get projects:', error); + res.status(500).json({ + success: false, + error: 'Failed to get projects', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +/** + * POST /api/v2/projects + * Create a new project + */ +router.post('/', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const user = req.user!; + const orgId = user.memberships[0]?.orgId; + + if (!orgId) { + return res.status(404).json({ + success: false, + error: 'No organization found', + message: 'User must belong to an organization to create projects' + }); + } + + // Validate input + const validatedData = createProjectSchema.parse(req.body); + + // Create project + const project = await prisma.project.create({ + data: { + name: validatedData.name, + orgId: orgId, + settingsJson: { + description: validatedData.description || '', + ...validatedData.settings, + }, + }, + }); + + logger.info(`Project created: ${project.id}`, { + userId: user.id, + orgId, + projectName: project.name, + }); + + res.status(201).json({ + success: true, + data: { + project: { + id: project.id, + name: project.name, + description: validatedData.description || null, + settings: project.settingsJson, + trackingCount: 0, + createdAt: project.createdAt, + }, + }, + message: 'Project created successfully', + }); + + } catch (error) { + logger.error('Failed to create project:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: error.errors, + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to create project', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +/** + * GET /api/v2/projects/:projectId + * Get project details + */ +router.get('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const user = req.user!; + const { projectId } = projectParamsSchema.parse(req.params); + const orgId = user.memberships[0]?.orgId; + + if (!orgId) { + return res.status(404).json({ + success: false, + error: 'No organization found', + }); + } + + const project = await prisma.project.findFirst({ + where: { + id: projectId, + orgId: orgId, + }, + include: { + _count: { + select: { + checks: true, + }, + }, + checks: { + take: 10, + orderBy: { + startedAt: 'desc', + }, + include: { + hops: { + select: { + url: true, + statusCode: true, + redirectType: true, + }, + }, + }, + }, + }, + }); + + if (!project) { + return res.status(404).json({ + success: false, + error: 'Project not found', + message: 'Project does not exist or you do not have access to it', + }); + } + + res.json({ + success: true, + data: { + project: { + id: project.id, + name: project.name, + description: (project.settingsJson as any)?.description || null, + settings: project.settingsJson, + trackingCount: project._count.checks, + recentChecks: project.checks.map(check => ({ + id: check.id, + inputUrl: check.inputUrl, + finalUrl: check.finalUrl, + status: check.status, + startedAt: check.startedAt, + hopCount: check.hops.length, + })), + createdAt: project.createdAt, + }, + }, + }); + + } catch (error) { + logger.error('Failed to get project:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Invalid project ID', + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to get project', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +/** + * PUT /api/v2/projects/:projectId + * Update project + */ +router.put('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const user = req.user!; + const { projectId } = projectParamsSchema.parse(req.params); + const orgId = user.memberships[0]?.orgId; + + if (!orgId) { + return res.status(404).json({ + success: false, + error: 'No organization found', + }); + } + + // Validate input + const validatedData = updateProjectSchema.parse(req.body); + + // Check if project exists and user has access + const existingProject = await prisma.project.findFirst({ + where: { + id: projectId, + orgId: orgId, + }, + }); + + if (!existingProject) { + return res.status(404).json({ + success: false, + error: 'Project not found', + message: 'Project does not exist or you do not have access to it', + }); + } + + // Update project + const currentSettings = existingProject.settingsJson as any || {}; + const project = await prisma.project.update({ + where: { + id: projectId, + }, + data: { + ...(validatedData.name && { name: validatedData.name }), + settingsJson: { + ...currentSettings, + ...(validatedData.description !== undefined && { description: validatedData.description }), + ...validatedData.settings, + }, + }, + }); + + logger.info(`Project updated: ${project.id}`, { + userId: user.id, + orgId, + changes: validatedData, + }); + + res.json({ + success: true, + data: { + project: { + id: project.id, + name: project.name, + description: (project.settingsJson as any)?.description || null, + settings: project.settingsJson, + createdAt: project.createdAt, + }, + }, + message: 'Project updated successfully', + }); + + } catch (error) { + logger.error('Failed to update project:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Validation failed', + details: error.errors, + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to update project', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +/** + * DELETE /api/v2/projects/:projectId + * Delete project + */ +router.delete('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const user = req.user!; + const { projectId } = projectParamsSchema.parse(req.params); + const orgId = user.memberships[0]?.orgId; + + if (!orgId) { + return res.status(404).json({ + success: false, + error: 'No organization found', + }); + } + + // Check if project exists and user has access + const existingProject = await prisma.project.findFirst({ + where: { + id: projectId, + orgId: orgId, + }, + include: { + _count: { + select: { + checks: true, + }, + }, + }, + }); + + if (!existingProject) { + return res.status(404).json({ + success: false, + error: 'Project not found', + message: 'Project does not exist or you do not have access to it', + }); + } + + // Prevent deletion of projects with tracking data + if (existingProject._count.checks > 0) { + return res.status(400).json({ + success: false, + error: 'Cannot delete project with tracking data', + message: `This project has ${existingProject._count.checks} tracking records. Please archive or move the data first.`, + }); + } + + // Delete project + await prisma.project.delete({ + where: { + id: projectId, + }, + }); + + logger.info(`Project deleted: ${projectId}`, { + userId: user.id, + orgId, + projectName: existingProject.name, + }); + + res.json({ + success: true, + message: 'Project deleted successfully', + }); + + } catch (error) { + logger.error('Failed to delete project:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Invalid project ID', + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to delete project', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + +export default router; diff --git a/apps/api/src/routes/tracking.routes.ts b/apps/api/src/routes/tracking.routes.ts index 30ec0b04..dbececca 100644 --- a/apps/api/src/routes/tracking.routes.ts +++ b/apps/api/src/routes/tracking.routes.ts @@ -8,6 +8,7 @@ import express from 'express'; import { z } from 'zod'; import rateLimit from 'express-rate-limit'; import { RedirectTrackerService } from '../services/redirect-tracker.service'; +import { prisma } from '../lib/prisma'; import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware'; import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware'; import { logger } from '../lib/logger'; @@ -103,14 +104,65 @@ router.post('/track', // Set project ID based on authentication status if (!validatedData.projectId) { if (req.user) { - // Authenticated user - use their default project + // Authenticated user - get their default project const userMembership = req.user.memberships[0]; if (userMembership) { - validatedData.projectId = 'default-project'; // Placeholder + // Find the user's default project + const defaultProject = await prisma.project.findFirst({ + where: { + orgId: userMembership.orgId + } + }); + + if (defaultProject) { + validatedData.projectId = defaultProject.id; + } else { + // Create a default project if none exists + const newProject = await prisma.project.create({ + data: { + name: 'Default Project', + orgId: userMembership.orgId, + settingsJson: {} + } + }); + validatedData.projectId = newProject.id; + } } } else { - // Anonymous user - use the anonymous project - validatedData.projectId = 'anonymous-project'; + // Anonymous user - create or find anonymous project + let anonymousProject = await prisma.project.findFirst({ + where: { + name: 'Anonymous Tracking' + } + }); + + if (!anonymousProject) { + // Need to create anonymous organization first + let anonymousOrg = await prisma.organization.findFirst({ + where: { + name: 'Anonymous Users' + } + }); + + if (!anonymousOrg) { + anonymousOrg = await prisma.organization.create({ + data: { + name: 'Anonymous Users', + plan: 'free' + } + }); + } + + anonymousProject = await prisma.project.create({ + data: { + name: 'Anonymous Tracking', + orgId: anonymousOrg.id, + settingsJson: {} + } + }); + } + + validatedData.projectId = anonymousProject.id; } } diff --git a/apps/api/src/services/redirect-tracker.service.ts b/apps/api/src/services/redirect-tracker.service.ts index 57c11693..ef9baca1 100644 --- a/apps/api/src/services/redirect-tracker.service.ts +++ b/apps/api/src/services/redirect-tracker.service.ts @@ -26,9 +26,9 @@ const trackRequestSchema = z.object({ followJS: z.boolean().default(false), // Future: JavaScript redirects maxHops: z.number().min(1).max(20).default(10), timeout: z.number().min(1000).max(30000).default(15000), - enableSSLAnalysis: z.boolean().default(false), - enableSEOAnalysis: z.boolean().default(false), - enableSecurityAnalysis: z.boolean().default(false), + enableSSLAnalysis: z.boolean().default(true), + enableSEOAnalysis: z.boolean().default(true), + enableSecurityAnalysis: z.boolean().default(true), }); export type TrackRequest = z.infer; @@ -59,6 +59,11 @@ export interface CheckResult { redirectCount: number; loopDetected?: boolean; error?: string; + analysis?: { + ssl?: any; + seo?: any; + security?: any; + }; } /** @@ -149,6 +154,9 @@ export class RedirectTrackerService { // Perform enhanced analysis on final URL if enabled await this.performEnhancedAnalysis(check.id, finalUrl || inputUrl, hops.map(h => h.url), validatedRequest); + // Fetch analysis results from database + const analysisData = await this.getAnalysisResults(check.id); + const result: CheckResult = { id: check.id, inputUrl, @@ -171,6 +179,7 @@ export class RedirectTrackerService { })), redirectCount, loopDetected, + analysis: analysisData, }; logger.info(`Enhanced redirect tracking completed: ${inputUrl}`, { @@ -608,4 +617,42 @@ export class RedirectTrackerService { // Don't throw - analysis failure shouldn't break the main tracking } } + + /** + * Fetch analysis results from database for a check + */ + private async getAnalysisResults(checkId: string): Promise { + try { + const [sslInspections, seoFlags, securityFlags] = await Promise.all([ + prisma.sslInspection.findMany({ + where: { checkId } + }), + prisma.seoFlags.findMany({ + where: { checkId } + }), + prisma.securityFlags.findMany({ + where: { checkId } + }) + ]); + + const analysis: any = {}; + + if (sslInspections.length > 0) { + analysis.ssl = sslInspections[0]; // Take the first (should be only one) + } + + if (seoFlags.length > 0) { + analysis.seo = seoFlags[0]; // Take the first (should be only one) + } + + if (securityFlags.length > 0) { + analysis.security = securityFlags[0]; // Take the first (should be only one) + } + + return Object.keys(analysis).length > 0 ? analysis : null; + } catch (error) { + logger.error(`Failed to fetch analysis results for check ${checkId}:`, error); + return null; + } + } } diff --git a/apps/web/index.html b/apps/web/index.html index e803366f..8fc7890d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,6 +5,16 @@ URL Tracker Tool V2 + + + +
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2224b505..ab105303 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,6 +26,9 @@ import { BulkUploadPage } from './pages/BulkUploadPage'; // Context providers import { AuthProvider } from './contexts/AuthContext'; +// Analytics +import { RouteTracker } from './components/Analytics/RouteTracker'; + // Create React Query client const queryClient = new QueryClient({ defaultOptions: { @@ -43,6 +46,7 @@ function App() { + {/* Public routes */} diff --git a/apps/web/src/components/Analytics/RouteTracker.tsx b/apps/web/src/components/Analytics/RouteTracker.tsx new file mode 100644 index 00000000..6234995c --- /dev/null +++ b/apps/web/src/components/Analytics/RouteTracker.tsx @@ -0,0 +1,20 @@ +/** + * Route Tracker Component + * + * Tracks page views for Google Analytics in SPA navigation + */ + +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { trackPageView } from '../../utils/analytics'; + +export function RouteTracker() { + const location = useLocation(); + + useEffect(() => { + // Track page view on route change + trackPageView(location.pathname + location.search, document.title); + }, [location]); + + return null; // This component doesn't render anything +} diff --git a/apps/web/src/contexts/AuthContext.tsx b/apps/web/src/contexts/AuthContext.tsx index 5d3fd8cd..2f37b05d 100644 --- a/apps/web/src/contexts/AuthContext.tsx +++ b/apps/web/src/contexts/AuthContext.tsx @@ -7,6 +7,7 @@ import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { useToast } from '@chakra-ui/react'; import { authApi, AuthUser, LoginRequest, RegisterRequest } from '../services/api'; +import { trackUserAuth } from '../utils/analytics'; interface AuthContextType { user: AuthUser | null; @@ -109,6 +110,9 @@ export function AuthProvider({ children }: AuthProviderProps) { authApi.logout(); setUser(null); + // Track logout + trackUserAuth('logout'); + toast({ title: 'Logged out', description: 'You have been successfully logged out.', diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 2807a6d9..e165e5b9 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -42,6 +42,18 @@ import { TabPanels, Tab, TabPanel, + Input, + Textarea, + FormControl, + FormLabel, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalFooter, + ModalBody, + ModalCloseButton, + useDisclosure, } from '@chakra-ui/react'; import { Link as RouterLink } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; @@ -58,13 +70,17 @@ import { } from 'react-icons/fi'; import { useAuth } from '../contexts/AuthContext'; -import { trackingApi } from '../services/api'; -// import type { CheckResult } from '../services/api'; // For future use +import { trackingApi, projectsApi, CreateProjectRequest } from '../services/api'; export function DashboardPage() { const { isAuthenticated, user } = useAuth(); const toast = useToast(); const [selectedPeriod, setSelectedPeriod] = useState('7d'); + const [newProjectName, setNewProjectName] = useState(''); + const [newProjectDescription, setNewProjectDescription] = useState(''); + const [isCreatingProject, setIsCreatingProject] = useState(false); + + const { isOpen: isCreateModalOpen, onOpen: onCreateModalOpen, onClose: onCreateModalClose } = useDisclosure(); const cardBg = useColorModeValue('white', 'gray.800'); const borderColor = useColorModeValue('gray.200', 'gray.700'); @@ -85,7 +101,7 @@ export function DashboardPage() { ); } - // Fetch recent checks + // Fetch recent checks (only when authenticated) const { data: recentChecks = [], isLoading: isLoadingChecks, @@ -95,6 +111,20 @@ export function DashboardPage() { queryKey: ['recentChecks'], queryFn: () => trackingApi.getRecentChecks(20), refetchInterval: 30000, // Auto-refresh every 30 seconds + enabled: isAuthenticated, // Only run when authenticated + }); + + // Fetch projects (only when authenticated) + const { + data: projects = [], + isLoading: isLoadingProjects, + error: projectsError, + refetch: refetchProjects + } = useQuery({ + queryKey: ['projects'], + queryFn: () => projectsApi.getProjects(), + refetchInterval: 60000, // Auto-refresh every minute + enabled: isAuthenticated, // Only run when authenticated }); // Calculate dashboard metrics @@ -130,6 +160,7 @@ export function DashboardPage() { const handleRefresh = () => { refetchChecks(); + refetchProjects(); toast({ title: 'Dashboard refreshed', status: 'success', @@ -138,6 +169,63 @@ export function DashboardPage() { }); }; + const handleCreateProject = async () => { + if (!newProjectName.trim()) { + toast({ + title: 'Project name required', + status: 'error', + duration: 3000, + isClosable: true, + }); + return; + } + + try { + setIsCreatingProject(true); + + const projectData: CreateProjectRequest = { + name: newProjectName.trim(), + description: newProjectDescription.trim() || undefined, + settings: { + defaultMethod: 'GET', + defaultTimeout: 15000, + defaultMaxHops: 10, + enableSSLAnalysis: true, + enableSEOAnalysis: true, + enableSecurityAnalysis: true, + }, + }; + + await projectsApi.createProject(projectData); + + // Reset form and close modal + setNewProjectName(''); + setNewProjectDescription(''); + onCreateModalClose(); + + // Refresh projects + refetchProjects(); + + toast({ + title: 'Project created successfully', + description: `${projectData.name} is ready for tracking`, + status: 'success', + duration: 3000, + isClosable: true, + }); + } catch (error: any) { + toast({ + title: 'Failed to create project', + description: error.response?.data?.message || 'Please try again', + status: 'error', + duration: 5000, + isClosable: true, + }); + } finally { + setIsCreatingProject(false); + } + }; + if (checksError) { return ( @@ -455,21 +543,98 @@ export function DashboardPage() { - Project Management + Your Projects - - - - Project management coming soon - - Organize your checks into projects for better tracking and team collaboration. - - + {isLoadingProjects ? ( + + + Loading projects... + + ) : projectsError ? ( + + + + Failed to load projects + + + + ) : projects.length === 0 ? ( + + + No projects yet + + Create your first project to organize your URL tracking + + + + ) : ( + + {projects.map((project) => ( + + + + + + {project.name} + + + {project.trackingCount} checks + + + + {project.description && ( + + {project.description} + + )} + + + + Created {formatTimeAgo(project.createdAt)} + + + + + + + ))} + + )} @@ -545,6 +710,51 @@ export function DashboardPage() { + + {/* Create Project Modal */} + + + + Create New Project + + + + + Project Name + setNewProjectName(e.target.value)} + /> + + + + Description +