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
This commit is contained in:
Andrei
2025-08-23 17:45:01 +00:00
parent 58f8093689
commit 6e41d9874d
19 changed files with 1904 additions and 95 deletions

View File

@@ -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() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<RouteTracker />
<Layout>
<Routes>
{/* Public routes */}

View File

@@ -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
}

View File

@@ -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.',

View File

@@ -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 (
<Container maxW="6xl">
@@ -455,21 +543,98 @@ export function DashboardPage() {
<CardHeader>
<HStack justify="space-between">
<Heading as="h3" size="md">
Project Management
Your Projects
</Heading>
<Button size="sm" colorScheme="brand" leftIcon={<Icon as={FiPlus} />}>
<Button
size="sm"
colorScheme="brand"
leftIcon={<Icon as={FiPlus} />}
onClick={onCreateModalOpen}
>
Create Project
</Button>
</HStack>
</CardHeader>
<CardBody>
<VStack py={8} spacing={4}>
<Icon as={FiBarChart} size="3rem" color="gray.400" />
<Text color="gray.600">Project management coming soon</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Organize your checks into projects for better tracking and team collaboration.
</Text>
</VStack>
{isLoadingProjects ? (
<VStack py={8}>
<Spinner size="lg" colorScheme="brand" />
<Text color="gray.600">Loading projects...</Text>
</VStack>
) : projectsError ? (
<VStack py={8} spacing={4}>
<Alert status="error">
<AlertIcon />
Failed to load projects
</Alert>
<Button size="sm" onClick={() => refetchProjects()}>
Retry
</Button>
</VStack>
) : projects.length === 0 ? (
<VStack py={8} spacing={4}>
<Icon as={FiBarChart} size="3rem" color="gray.400" />
<Text color="gray.600">No projects yet</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Create your first project to organize your URL tracking
</Text>
<Button
colorScheme="brand"
size="sm"
onClick={onCreateModalOpen}
leftIcon={<Icon as={FiPlus} />}
>
Create First Project
</Button>
</VStack>
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{projects.map((project) => (
<Card key={project.id} bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Heading as="h4" size="sm" noOfLines={1}>
{project.name}
</Heading>
<Badge colorScheme="blue" variant="subtle">
{project.trackingCount} checks
</Badge>
</HStack>
{project.description && (
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{project.description}
</Text>
)}
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
Created {formatTimeAgo(project.createdAt)}
</Text>
<Button
size="xs"
variant="ghost"
leftIcon={<Icon as={FiEye} />}
onClick={() => {
// Navigate to project details (future feature)
toast({
title: 'Project details',
description: 'Project detail view coming soon',
status: 'info',
duration: 2000,
});
}}
>
View
</Button>
</HStack>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
)}
</CardBody>
</Card>
</TabPanel>
@@ -545,6 +710,51 @@ export function DashboardPage() {
</TabPanel>
</TabPanels>
</Tabs>
{/* Create Project Modal */}
<Modal isOpen={isCreateModalOpen} onClose={onCreateModalClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Create New Project</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Project Name</FormLabel>
<Input
placeholder="Enter project name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel>Description</FormLabel>
<Textarea
placeholder="Describe your project (optional)"
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
rows={3}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onCreateModalClose}>
Cancel
</Button>
<Button
colorScheme="brand"
onClick={handleCreateProject}
isLoading={isCreatingProject}
loadingText="Creating..."
>
Create Project
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</Container>
);

View File

@@ -3,6 +3,7 @@
*/
import { useState } from 'react';
import { trackUrlSubmission, trackAnalysisResult, trackError } from '../utils/analytics';
import {
Box,
Heading,
@@ -49,7 +50,7 @@ import {
// Image,
// Center,
} from '@chakra-ui/react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { Link as RouterLink } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -90,7 +91,6 @@ export function HomePage() {
const [showAdvanced, setShowAdvanced] = useState(false);
const [trackingResult, setTrackingResult] = useState<any>(null);
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const cardBg = useColorModeValue('white', 'gray.800');
@@ -124,6 +124,8 @@ export function HomePage() {
// Tracking mutation
const trackingMutation = useMutation({
mutationFn: async (data: TrackRequestV2) => {
// Track URL submission
trackUrlSubmission(data.url, isAuthenticated);
return await trackingApi.trackUrlV2(data);
},
onSuccess: (result) => {
@@ -136,12 +138,24 @@ export function HomePage() {
isClosable: true,
});
// Navigate to check detail page for authenticated users
if (isAuthenticated) {
navigate(`/check/${result.check.id}`);
// Track analysis results
const analysis = (result as any).check?.analysis;
if (analysis) {
trackAnalysisResult(
(result as any).check.redirectCount || 0,
analysis.ssl?.warningsJson?.length > 0 || false,
!analysis.seo?.robotsTxtStatus || analysis.seo?.noindex || false,
analysis.security?.mixedContent === 'PRESENT' || analysis.security?.safeBrowsingStatus !== 'safe' || false
);
}
// Don't auto-navigate on home page to allow users to see results
// They can manually navigate to their dashboard to see saved results
},
onError: (error: any) => {
// Track errors
trackError('tracking_failed', error.response?.data?.message || error.message || 'Unknown error');
toast({
title: 'Tracking failed',
description: error.response?.data?.message || 'An error occurred',

View File

@@ -1,24 +1,86 @@
/**
* Login Page - Placeholder for Phase 4
* Login Page - User Authentication
*/
import { useState } from 'react';
import { trackUserAuth } from '../utils/analytics';
import {
Box,
Heading,
Text,
Container,
VStack,
Badge,
HStack,
Card,
CardBody,
Button,
FormControl,
FormLabel,
Input,
FormErrorMessage,
InputGroup,
InputRightElement,
IconButton,
Alert,
AlertIcon,
Divider,
useColorModeValue,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { useAuth } from '../contexts/AuthContext';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setIsLoading(true);
await login(data);
// Track successful login
trackUserAuth('login');
// Redirect to intended page or dashboard
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
} catch (error: any) {
setError('root', {
message: error.response?.data?.message || 'Login failed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
return (
<Container maxW="md">
<Container maxW="md" py={12}>
<VStack spacing={8}>
<Box textAlign="center">
<Heading as="h1" size="xl" mb={4}>
@@ -27,40 +89,94 @@ export function LoginPage() {
<Text color="gray.600">
Access your redirect tracking dashboard
</Text>
<Badge colorScheme="yellow" mt={2}>
Login UI coming in Phase 4
</Badge>
</Box>
<Card w="full">
<CardBody textAlign="center" py={8}>
<Text mb={6} color="gray.600">
The authentication system is fully implemented in the backend API.
The login form UI will be completed in the next phase.
</Text>
<VStack spacing={4}>
<Text fontSize="sm" fontWeight="medium">
Backend Features Ready:
</Text>
<VStack fontSize="sm" color="gray.600">
<Text> User registration and login</Text>
<Text> JWT token authentication</Text>
<Text> Argon2 password hashing</Text>
<Text> Organization management</Text>
<Card w="full" bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody p={8}>
{errors.root && (
<Alert status="error" mb={6} borderRadius="md">
<AlertIcon />
{errors.root.message}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={6}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input
type="email"
placeholder="Enter your email"
autoComplete="email"
{...register('email')}
/>
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
autoComplete="current-password"
{...register('password')}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
icon={showPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="brand"
size="lg"
w="full"
isLoading={isLoading}
loadingText="Signing in..."
>
Sign In
</Button>
</VStack>
</form>
<Divider my={6} />
<VStack spacing={4}>
<HStack>
<Text fontSize="sm" color="gray.600">
Don't have an account?
</Text>
<Button
as={RouterLink}
to="/register"
variant="link"
colorScheme="brand"
size="sm"
>
Sign up
</Button>
</HStack>
<Button
as={RouterLink}
to="/track"
variant="outline"
size="sm"
w="full"
>
Continue as Guest
</Button>
</VStack>
</CardBody>
</Card>
<VStack>
<Button as={RouterLink} to="/track" colorScheme="brand">
Try Anonymous Tracking
</Button>
<Text fontSize="sm" color="gray.600">
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/login</Text>
</Text>
</VStack>
</VStack>
</Container>
);

View File

@@ -1,24 +1,119 @@
/**
* Register Page - Placeholder for Phase 4
* Register Page - User Registration
*/
import { useState } from 'react';
import { trackUserAuth } from '../utils/analytics';
import {
Box,
Heading,
Text,
Container,
VStack,
Badge,
HStack,
Card,
CardBody,
Button,
FormControl,
FormLabel,
Input,
FormErrorMessage,
InputGroup,
InputRightElement,
IconButton,
Alert,
AlertIcon,
Divider,
useColorModeValue,
SimpleGrid,
List,
ListItem,
ListIcon,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FiEye, FiEyeOff, FiCheck } from 'react-icons/fi';
import { useAuth } from '../contexts/AuthContext';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export function RegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { register: registerUser } = useAuth();
const navigate = useNavigate();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const {
register,
handleSubmit,
formState: { errors },
setError,
watch,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const password = watch('password');
const onSubmit = async (data: RegisterFormData) => {
try {
setIsLoading(true);
await registerUser({
name: data.name,
email: data.email,
password: data.password,
});
// Track successful registration
trackUserAuth('register');
// Navigate to dashboard after successful registration
navigate('/dashboard', { replace: true });
} catch (error: any) {
setError('root', {
message: error.response?.data?.message || 'Registration failed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
const getPasswordValidationColor = (valid: boolean) => {
if (!password) return 'gray.500';
return valid ? 'green.500' : 'red.500';
};
const passwordValidations = [
{ label: 'At least 8 characters', valid: password?.length >= 8 },
{ label: 'One uppercase letter', valid: /[A-Z]/.test(password || '') },
{ label: 'One lowercase letter', valid: /[a-z]/.test(password || '') },
{ label: 'One number', valid: /[0-9]/.test(password || '') },
{ label: 'One special character', valid: /[^A-Za-z0-9]/.test(password || '') },
];
return (
<Container maxW="md">
<Container maxW="lg" py={12}>
<VStack spacing={8}>
<Box textAlign="center">
<Heading as="h1" size="xl" mb={4}>
@@ -27,40 +122,190 @@ export function RegisterPage() {
<Text color="gray.600">
Get started with enhanced redirect tracking
</Text>
<Badge colorScheme="yellow" mt={2}>
Registration UI coming in Phase 4
</Badge>
</Box>
<Card w="full">
<CardBody textAlign="center" py={8}>
<Text mb={6} color="gray.600">
The user registration system is fully implemented in the backend API.
The registration form UI will be completed in the next phase.
</Text>
<VStack spacing={4}>
<Text fontSize="sm" fontWeight="medium">
Account Benefits:
</Text>
<VStack fontSize="sm" color="gray.600">
<Text>🚀 Higher rate limits (200/hour)</Text>
<Text>💾 Saved tracking history</Text>
<Text>📊 Analysis dashboards</Text>
<Text>🏢 Organization management</Text>
</VStack>
</VStack>
</CardBody>
</Card>
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={8} w="full">
{/* Registration Form */}
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody p={8}>
{errors.root && (
<Alert status="error" mb={6} borderRadius="md">
<AlertIcon />
{errors.root.message}
</Alert>
)}
<VStack>
<Button as={RouterLink} to="/track" colorScheme="brand">
Try Anonymous Tracking
</Button>
<Text fontSize="sm" color="gray.600">
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/register</Text>
</Text>
</VStack>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={6}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>Full Name</FormLabel>
<Input
placeholder="Enter your full name"
autoComplete="name"
{...register('name')}
/>
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input
type="email"
placeholder="Enter your email"
autoComplete="email"
{...register('email')}
/>
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Create a strong password"
autoComplete="new-password"
{...register('password')}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
icon={showPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.confirmPassword}>
<FormLabel>Confirm Password</FormLabel>
<InputGroup>
<Input
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
autoComplete="new-password"
{...register('confirmPassword')}
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
icon={showConfirmPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.confirmPassword?.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="brand"
size="lg"
w="full"
isLoading={isLoading}
loadingText="Creating account..."
>
Create Account
</Button>
</VStack>
</form>
<Divider my={6} />
<VStack spacing={4}>
<HStack>
<Text fontSize="sm" color="gray.600">
Already have an account?
</Text>
<Button
as={RouterLink}
to="/login"
variant="link"
colorScheme="brand"
size="sm"
>
Sign in
</Button>
</HStack>
<Button
as={RouterLink}
to="/track"
variant="outline"
size="sm"
w="full"
>
Continue as Guest
</Button>
</VStack>
</CardBody>
</Card>
{/* Benefits & Password Requirements */}
<VStack spacing={6} align="stretch">
{/* Account Benefits */}
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<Heading size="md" mb={4}>Account Benefits</Heading>
<List spacing={2}>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Higher rate limits (1000/hour)
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Saved tracking history
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Analysis dashboards
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Organization management
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
API key access
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Bulk URL processing
</ListItem>
</List>
</CardBody>
</Card>
{/* Password Requirements */}
{password && (
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<Heading size="md" mb={4}>Password Requirements</Heading>
<List spacing={2}>
{passwordValidations.map((validation, index) => (
<ListItem key={index}>
<ListIcon
as={FiCheck}
color={getPasswordValidationColor(validation.valid)}
/>
<Text
as="span"
color={getPasswordValidationColor(validation.valid)}
fontSize="sm"
>
{validation.label}
</Text>
</ListItem>
))}
</List>
</CardBody>
</Card>
)}
</VStack>
</SimpleGrid>
</VStack>
</Container>
);

View File

@@ -7,7 +7,7 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
// Base configuration
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3333';
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3334';
// Create axios instance
const api: AxiosInstance = axios.create({
@@ -199,6 +199,61 @@ export interface SecurityAnalysisResult {
securityScore: number;
}
// Project Management Types
export interface Project {
id: string;
name: string;
description: string | null;
settings: {
description?: string;
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
trackingCount: number;
createdAt: string;
}
export interface ProjectDetails extends Project {
recentChecks: Array<{
id: string;
inputUrl: string;
finalUrl: string | null;
status: string;
startedAt: string;
hopCount: number;
}>;
}
export interface CreateProjectRequest {
name: string;
description?: string;
settings?: {
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
settings?: {
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
}
// ============================================================================
// API SERVICES
// ============================================================================
@@ -300,6 +355,32 @@ export const analysisApi = {
},
};
export const projectsApi = {
async getProjects(): Promise<Project[]> {
const response: AxiosResponse<{ data: { projects: Project[] } }> = await api.get('/v2/projects');
return response.data.data.projects;
},
async getProject(projectId: string): Promise<ProjectDetails> {
const response: AxiosResponse<{ data: { project: ProjectDetails } }> = await api.get(`/v2/projects/${projectId}`);
return response.data.data.project;
},
async createProject(projectData: CreateProjectRequest): Promise<Project> {
const response: AxiosResponse<{ data: { project: Project } }> = await api.post('/v2/projects', projectData);
return response.data.data.project;
},
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
const response: AxiosResponse<{ data: { project: Project } }> = await api.put(`/v2/projects/${projectId}`, updates);
return response.data.data.project;
},
async deleteProject(projectId: string): Promise<void> {
await api.delete(`/v2/projects/${projectId}`);
},
};
export const healthApi = {
async getHealth(): Promise<{
status: string;

View File

@@ -0,0 +1,110 @@
/**
* Google Analytics Utility Functions
*
* Provides type-safe Google Analytics event tracking
*/
// Extend the Window interface to include gtag
declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetId: string | Date,
config?: Record<string, any>
) => void;
dataLayer: any[];
}
}
/**
* Track a custom event in Google Analytics
*/
export const trackEvent = (
eventName: string,
parameters?: {
event_category?: string;
event_label?: string;
value?: number;
custom_parameters?: Record<string, any>;
}
) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', eventName, {
event_category: parameters?.event_category,
event_label: parameters?.event_label,
value: parameters?.value,
...parameters?.custom_parameters,
});
}
};
/**
* Track URL tracking events
*/
export const trackUrlSubmission = (url: string, isAuthenticated: boolean) => {
trackEvent('url_track', {
event_category: 'tracking',
event_label: isAuthenticated ? 'authenticated' : 'anonymous',
custom_parameters: {
url_domain: new URL(url).hostname,
user_type: isAuthenticated ? 'authenticated' : 'anonymous',
},
});
};
/**
* Track user authentication events
*/
export const trackUserAuth = (action: 'login' | 'register' | 'logout') => {
trackEvent('user_auth', {
event_category: 'authentication',
event_label: action,
});
};
/**
* Track page views (for SPA navigation)
*/
export const trackPageView = (pagePath: string, pageTitle?: string) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('config', 'G-ZDZ26XYN2P', {
page_path: pagePath,
page_title: pageTitle,
});
}
};
/**
* Track analysis results
*/
export const trackAnalysisResult = (
redirectCount: number,
hasSSLIssues: boolean,
hasSEOIssues: boolean,
hasSecurityIssues: boolean
) => {
trackEvent('analysis_complete', {
event_category: 'analysis',
event_label: 'redirect_analysis',
value: redirectCount,
custom_parameters: {
redirect_count: redirectCount,
ssl_issues: hasSSLIssues,
seo_issues: hasSEOIssues,
security_issues: hasSecurityIssues,
},
});
};
/**
* Track errors
*/
export const trackError = (errorType: string, errorMessage: string) => {
trackEvent('error', {
event_category: 'error',
event_label: errorType,
custom_parameters: {
error_message: errorMessage,
},
});
};