gitea
This commit is contained in:
3
frontend/.eslintrc.json
Normal file
3
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
211
frontend/app/analytics/page.tsx
Normal file
211
frontend/app/analytics/page.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type TimeRange = '7d' | '30d' | '90d' | 'all'
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const totalMonitors = monitors.length
|
||||
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const avgFrequency = totalMonitors > 0
|
||||
? Math.round(monitors.reduce((sum: number, m: any) => sum + m.frequency, 0) / totalMonitors)
|
||||
: 0
|
||||
|
||||
// Calculate additional stats
|
||||
const pausedMonitors = monitors.filter((m: any) => m.status === 'paused').length
|
||||
const recentChanges = monitors.filter((m: any) => {
|
||||
if (!m.last_change_at) return false
|
||||
const changeDate = new Date(m.last_change_at)
|
||||
const daysAgo = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : timeRange === '90d' ? 90 : 365
|
||||
const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
|
||||
return changeDate >= cutoff
|
||||
}).length
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={timeRange === range ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(range)}
|
||||
>
|
||||
{range === 'all' ? 'All Time' : range === '7d' ? 'Last 7 Days' : range === '30d' ? 'Last 30 Days' : 'Last 90 Days'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Monitors</p>
|
||||
<p className="text-3xl font-bold">{totalMonitors}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Uptime Rate</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{totalMonitors > 0 ? Math.round((activeMonitors / totalMonitors) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Error Rate</p>
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
{totalMonitors > 0 ? Math.round((errorMonitors / totalMonitors) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg. Frequency</p>
|
||||
<p className="text-3xl font-bold">{avgFrequency} min</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Placeholder */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monitor Status Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="relative h-40 w-40">
|
||||
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="hsl(var(--muted))" strokeWidth="12" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="hsl(var(--success))"
|
||||
strokeWidth="12"
|
||||
strokeDasharray={`${(activeMonitors / (totalMonitors || 1)) * 251.2} 251.2`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold">{activeMonitors}</span>
|
||||
<span className="text-xs text-muted-foreground">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm">Active ({activeMonitors})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<span className="text-sm">Error ({errorMonitors})</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Check Frequency Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: '5 min', count: monitors.filter((m: any) => m.frequency === 5).length },
|
||||
{ label: '30 min', count: monitors.filter((m: any) => m.frequency === 30).length },
|
||||
{ label: '1 hour', count: monitors.filter((m: any) => m.frequency === 60).length },
|
||||
{ label: '6 hours', count: monitors.filter((m: any) => m.frequency === 360).length },
|
||||
{ label: '24 hours', count: monitors.filter((m: any) => m.frequency === 1440).length },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 text-sm text-muted-foreground">{item.label}</span>
|
||||
<div className="flex-1 h-4 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${totalMonitors > 0 ? (item.count / totalMonitors) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-sm font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
71
frontend/app/api/proxy/route.ts
Normal file
71
frontend/app/api/proxy/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const targetUrl = searchParams.get('url')
|
||||
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json({ error: 'URL parameter required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
const url = new URL(targetUrl)
|
||||
|
||||
// Only allow http/https
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return NextResponse.json({ error: 'Invalid URL protocol' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch the page
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch: ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
let html = await response.text()
|
||||
|
||||
// Inject base tag to fix relative URLs
|
||||
const baseTag = `<base href="${url.origin}${url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1)}">`
|
||||
html = html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
|
||||
|
||||
// Disable all scripts for security
|
||||
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
|
||||
// Remove event handlers
|
||||
html = html.replace(/\son\w+="[^"]*"/gi, '')
|
||||
html = html.replace(/\son\w+='[^']*'/gi, '')
|
||||
|
||||
// Add visual selector helper styles
|
||||
const helperStyles = `
|
||||
<style>
|
||||
* { cursor: crosshair !important; }
|
||||
a { pointer-events: none; }
|
||||
</style>
|
||||
`
|
||||
html = html.replace('</head>', `${helperStyles}</head>`)
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Proxy] Error fetching URL:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch URL' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,253 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newMonitor, setNewMonitor] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const handleAddMonitor = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await monitorAPI.create(newMonitor)
|
||||
setNewMonitor({ url: '', name: '', frequency: 60 })
|
||||
setShowAddForm(false)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to create monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
try {
|
||||
await monitorAPI.check(id)
|
||||
alert('Check triggered! Results will appear shortly.')
|
||||
setTimeout(() => refetch(), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this monitor?')) return
|
||||
|
||||
try {
|
||||
await monitorAPI.delete(id)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Website Monitor</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Your Monitors</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{monitors.length} monitor{monitors.length !== 1 ? 's' : ''} active
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
+ Add Monitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Monitor Form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow">
|
||||
<h3 className="mb-4 text-lg font-semibold">Add New Monitor</h3>
|
||||
<form onSubmit={handleAddMonitor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, url: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, name: e.target.value })
|
||||
}
|
||||
placeholder="My Monitor"
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Check Frequency (minutes)
|
||||
</label>
|
||||
<select
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
frequency: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every hour</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Monitor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded-md border px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitors List */}
|
||||
{monitors.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="mb-4 text-gray-600">No monitors yet</p>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Your First Monitor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{monitors.map((monitor: any) => (
|
||||
<div
|
||||
key={monitor.id}
|
||||
className="rounded-lg bg-white p-6 shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{monitor.name}</h3>
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span>Every {monitor.frequency} min</span>
|
||||
<span className="capitalize">Status: {monitor.status}</span>
|
||||
{monitor.last_checked_at && (
|
||||
<span>
|
||||
Last checked:{' '}
|
||||
{new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Check Now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
className="rounded-md border border-red-200 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Dashboard" description="Overview of your monitors">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const recentChanges = monitors.filter((m: any) => m.last_change_at).length
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Dashboard" description="Overview of your monitoring activity">
|
||||
{/* Stats Grid */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Monitors</p>
|
||||
<p className="text-2xl font-bold">{monitors.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active</p>
|
||||
<p className="text-2xl font-bold">{activeMonitors}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Errors</p>
|
||||
<p className="text-2xl font-bold">{errorMonitors}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Recent Changes</p>
|
||||
<p className="text-2xl font-bold">{recentChanges}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Quick Actions</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={() => router.push('/monitors')}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Monitor
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/incidents')}>
|
||||
View Incidents
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/analytics')}>
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Monitors */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Recent Monitors</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')}>
|
||||
View All →
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{monitors.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">No monitors yet</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
Start monitoring your first website
|
||||
</p>
|
||||
<Button onClick={() => router.push('/monitors')}>
|
||||
Create Your First Monitor
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{monitors.slice(0, 5).map((monitor: any) => (
|
||||
<Card key={monitor.id} hover onClick={() => router.push(`/monitors/${monitor.id}`)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`status-dot ${monitor.status === 'active' ? 'status-dot-success' : monitor.status === 'error' ? 'status-dot-error' : 'status-dot-neutral'}`} />
|
||||
<div>
|
||||
<h3 className="font-medium">{monitor.name || monitor.url}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate max-w-md">{monitor.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
142
frontend/app/forgot-password/page.tsx
Normal file
142
frontend/app/forgot-password/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await authAPI.forgotPassword(email)
|
||||
setEmailSent(true)
|
||||
toast.success('Check your email for password reset instructions')
|
||||
} catch (error: any) {
|
||||
console.error('Forgot password error:', error)
|
||||
// Show generic success message for security (prevent email enumeration)
|
||||
setEmailSent(true)
|
||||
toast.success('Check your email for password reset instructions')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
{emailSent
|
||||
? 'Check your email for instructions'
|
||||
: 'Enter your email to receive password reset instructions'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!emailSent ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Email Sent!</p>
|
||||
<p className="mt-1 text-sm text-green-700">
|
||||
If an account exists with <strong>{email}</strong>, you will receive password reset instructions shortly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">Didn't receive an email?</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Check your spam folder</li>
|
||||
<li>Make sure you entered the correct email</li>
|
||||
<li>Wait a few minutes and try again</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setEmailSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Try Different Email
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +1,694 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
/* Import Premium Fonts: Space Grotesk (headlines) + Inter Tight (body/UI) - MUST be first */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Premium Warm Palette - Extracted from User Image */
|
||||
--background: 40 11% 97%;
|
||||
/* #F9F8F6 */
|
||||
--foreground: 30 10% 20%;
|
||||
/* Dark Charcoal for text */
|
||||
|
||||
--card: 0 0% 100%;
|
||||
/* #FFFFFF */
|
||||
--card-foreground: 30 10% 20%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 30 10% 20%;
|
||||
|
||||
--primary: 34 29% 70%;
|
||||
/* #C9B59C - Sand/Gold Accent */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 30 24% 91%;
|
||||
/* #EFE9E3 - Light Beige */
|
||||
--secondary-foreground: 30 10% 20%;
|
||||
|
||||
--muted: 27 18% 82%;
|
||||
/* #D9CFC7 - Taupe/Grayish */
|
||||
--muted-foreground: 30 8% 45%;
|
||||
|
||||
--accent: 34 29% 70%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
|
||||
--border: 27 18% 82%;
|
||||
--input: 27 18% 82%;
|
||||
--ring: 34 29% 70%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* New Accent Colors for Landing Page */
|
||||
--burgundy: 349 67% 36%;
|
||||
/* #8B2635 - Deep burgundy for "change detected" */
|
||||
--teal: 177 35% 28%;
|
||||
/* #2D5F5D - Deep teal for "signal/filtered" */
|
||||
--noise-bg: 40 11% 96%;
|
||||
/* #F5F5F3 - Very light gray with texture */
|
||||
|
||||
/* Section Background Variations for Visual Rhythm */
|
||||
--section-bg-1: 40 11% 97%;
|
||||
/* Cream/Off-White - Hero */
|
||||
--section-bg-2: 30 15% 95%;
|
||||
/* Warmer Beige - Stats */
|
||||
--section-bg-3: 40 8% 96%;
|
||||
/* Kühler Grau - Use Cases */
|
||||
--section-bg-4: 35 20% 94%;
|
||||
/* Warmes Taupe - How It Works */
|
||||
--section-bg-5: 25 12% 93%;
|
||||
/* Sandstone - Differentiators */
|
||||
--section-bg-6: 177 10% 94%;
|
||||
/* Sehr leichtes Teal - Pricing */
|
||||
--section-bg-7: 349 8% 95%;
|
||||
/* Sehr leichtes Burgundy - Social Proof */
|
||||
}
|
||||
|
||||
/* Dark theme following the warm palette aesthetic */
|
||||
.dark {
|
||||
--background: 30 15% 10%;
|
||||
--foreground: 40 14% 92%;
|
||||
|
||||
--card: 30 12% 14%;
|
||||
--card-foreground: 40 14% 92%;
|
||||
|
||||
--popover: 30 12% 14%;
|
||||
--popover-foreground: 40 14% 92%;
|
||||
|
||||
--primary: 32 35% 55%;
|
||||
--primary-foreground: 30 15% 10%;
|
||||
|
||||
--secondary: 30 12% 20%;
|
||||
--secondary-foreground: 40 14% 92%;
|
||||
|
||||
--muted: 30 12% 20%;
|
||||
--muted-foreground: 35 10% 60%;
|
||||
|
||||
--accent: 32 35% 55%;
|
||||
--accent-foreground: 30 15% 10%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
|
||||
--border: 30 12% 24%;
|
||||
--input: 30 12% 24%;
|
||||
--ring: 32 35% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Typography Classes - using next/font CSS variables */
|
||||
.font-display {
|
||||
font-family: var(--font-display), 'Space Grotesk', system-ui, sans-serif;
|
||||
font-feature-settings: 'ss01';
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.font-body {
|
||||
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-accent {
|
||||
font-family: var(--font-display), 'Space Grotesk', monospace;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Typography Size Utilities */
|
||||
.text-display-xl {
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.text-display-lg {
|
||||
font-size: clamp(2.5rem, 6vw, 5rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.text-body-lg {
|
||||
font-size: clamp(1.125rem, 2vw, 1.5rem);
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Premium UI Utilities */
|
||||
@layer components {
|
||||
|
||||
/* Glass Panel Effect */
|
||||
.glass-panel {
|
||||
@apply bg-card/80 backdrop-blur-md border border-border/50 shadow-lg;
|
||||
}
|
||||
|
||||
/* Premium Card with subtle shadow and hover effect */
|
||||
.premium-card {
|
||||
@apply bg-card rounded-xl border border-border/50 shadow-sm transition-all duration-300;
|
||||
@apply hover:shadow-md hover:border-primary/30;
|
||||
}
|
||||
|
||||
/* Glassmorphism Cards - Premium frosted glass effect */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.glass-card-dark {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Gradient Accent Background */
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%);
|
||||
}
|
||||
|
||||
/* New Gradient Combinations */
|
||||
.gradient-primary-teal {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 100%);
|
||||
}
|
||||
|
||||
.gradient-teal-burgundy {
|
||||
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(var(--burgundy)) 100%);
|
||||
}
|
||||
|
||||
.gradient-warm {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(34 40% 60%) 100%);
|
||||
}
|
||||
|
||||
.gradient-cool {
|
||||
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(200 30% 50%) 100%);
|
||||
}
|
||||
|
||||
/* Status indicator dots */
|
||||
.status-dot {
|
||||
@apply w-2.5 h-2.5 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot-success {
|
||||
@apply status-dot bg-green-500;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-error {
|
||||
@apply status-dot bg-red-500;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
@apply status-dot bg-yellow-500;
|
||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-neutral {
|
||||
@apply status-dot bg-gray-400;
|
||||
}
|
||||
|
||||
/* Animated skeleton loading */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-muted rounded;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px rgba(196, 178, 156, 0.4);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 12px rgba(196, 178, 156, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Grain Texture Overlay */
|
||||
@keyframes grain {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(-5%, -10%);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-15%, 5%);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(7%, -25%);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(-5%, 25%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-15%, 10%);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(15%, 0%);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(0%, 15%);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(3%, 35%);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(-10%, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.grain-texture::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
animation: grain 8s steps(10) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Stagger Animation Delays */
|
||||
.stagger-1 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.stagger-2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.stagger-3 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.stagger-4 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.stagger-5 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.stagger-6 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
/* Enhanced Animations for Phase 2 */
|
||||
|
||||
/* Smooth Scale In with Spring */
|
||||
@keyframes scaleInSpring {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Blur to Sharp */
|
||||
@keyframes blurToSharp {
|
||||
from {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Letter Spacing Animation */
|
||||
@keyframes letterSpacing {
|
||||
from {
|
||||
letter-spacing: 0.2em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
letter-spacing: normal;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rubber Band Effect */
|
||||
@keyframes rubberBand {
|
||||
0% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: scale3d(1.25, 0.75, 1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale3d(0.75, 1.25, 1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale3d(1.15, 0.85, 1);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: scale3d(0.95, 1.05, 1);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scale3d(1.05, 0.95, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Glow Pulse */
|
||||
@keyframes glowPulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px hsl(var(--teal) / 0.3), 0 0 10px hsl(var(--teal) / 0.2);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 20px hsl(var(--teal) / 0.6), 0 0 30px hsl(var(--teal) / 0.3), 0 0 40px hsl(var(--teal) / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Gradient Shift */
|
||||
@keyframes gradientShift {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Effect */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%) skewX(-20deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(200%) skewX(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple Effect */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce In */
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3) translateY(-50px);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Float Animation */
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rotate 3D */
|
||||
@keyframes rotate3d {
|
||||
0% {
|
||||
transform: perspective(1000px) rotateY(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: perspective(1000px) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.animate-scale-in-spring {
|
||||
animation: scaleInSpring 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-blur-to-sharp {
|
||||
animation: blurToSharp 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-letter-spacing {
|
||||
animation: letterSpacing 1s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-rubber-band {
|
||||
animation: rubberBand 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-shift {
|
||||
animation: gradientShift 3s ease infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-ripple {
|
||||
animation: ripple 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounceIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-rotate-3d {
|
||||
animation: rotate3d 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-size: 200% auto;
|
||||
}
|
||||
|
||||
.gradient-text-animated {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-size: 200% auto;
|
||||
animation: gradientShift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.grain-texture::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
266
frontend/app/incidents/page.tsx
Normal file
266
frontend/app/incidents/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type FilterType = 'all' | 'errors' | 'changes'
|
||||
|
||||
interface Incident {
|
||||
id: string
|
||||
monitorId: string
|
||||
monitorName: string
|
||||
monitorUrl: string
|
||||
type: 'error' | 'change'
|
||||
timestamp: Date
|
||||
details?: string
|
||||
}
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [resolvedIds, setResolvedIds] = useState<Set<string>>(new Set())
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Incidents" description="View detected changes and errors">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
|
||||
// Build incidents list from monitors
|
||||
const incidents: Incident[] = monitors.flatMap((m: any) => {
|
||||
const result: Incident[] = []
|
||||
|
||||
if (m.status === 'error') {
|
||||
result.push({
|
||||
id: `error-${m.id}`,
|
||||
monitorId: m.id,
|
||||
monitorName: m.name || m.url,
|
||||
monitorUrl: m.url,
|
||||
type: 'error',
|
||||
timestamp: new Date(m.updated_at || m.created_at),
|
||||
details: m.last_error || 'Connection failed'
|
||||
})
|
||||
}
|
||||
|
||||
if (m.last_change_at) {
|
||||
result.push({
|
||||
id: `change-${m.id}`,
|
||||
monitorId: m.id,
|
||||
monitorName: m.name || m.url,
|
||||
monitorUrl: m.url,
|
||||
type: 'change',
|
||||
timestamp: new Date(m.last_change_at),
|
||||
details: 'Content changed'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}).sort((a: Incident, b: Incident) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
|
||||
// Apply filters
|
||||
const filteredIncidents = incidents.filter(incident => {
|
||||
if (!showResolved && resolvedIds.has(incident.id)) return false
|
||||
if (filter === 'errors') return incident.type === 'error'
|
||||
if (filter === 'changes') return incident.type === 'change'
|
||||
return true
|
||||
})
|
||||
|
||||
const errorCount = incidents.filter(i => i.type === 'error').length
|
||||
const changeCount = incidents.filter(i => i.type === 'change').length
|
||||
|
||||
const toggleResolved = (id: string) => {
|
||||
setResolvedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Incidents" description="View detected changes and errors">
|
||||
{/* Filter Tabs */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All ({incidents.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'errors' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('errors')}
|
||||
className={filter !== 'errors' && errorCount > 0 ? 'border-red-200 text-red-600' : ''}
|
||||
>
|
||||
🔴 Errors ({errorCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'changes' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('changes')}
|
||||
className={filter !== 'changes' && changeCount > 0 ? 'border-blue-200 text-blue-600' : ''}
|
||||
>
|
||||
🔵 Changes ({changeCount})
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowResolved(!showResolved)}
|
||||
>
|
||||
{showResolved ? 'Hide Resolved' : 'Show Resolved'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredIncidents.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">All Clear!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{filter === 'all'
|
||||
? 'No incidents or changes detected'
|
||||
: filter === 'errors'
|
||||
? 'No errors to show'
|
||||
: 'No changes to show'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredIncidents.map((incident) => (
|
||||
<Card
|
||||
key={incident.id}
|
||||
className={`transition-all ${resolvedIds.has(incident.id) ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${incident.type === 'error' ? 'bg-red-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
{incident.type === 'error' ? (
|
||||
<svg className="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{incident.monitorName}</h3>
|
||||
<Badge variant={incident.type === 'error' ? 'destructive' : 'default'}>
|
||||
{incident.type === 'error' ? 'Error' : 'Changed'}
|
||||
</Badge>
|
||||
{resolvedIds.has(incident.id) && (
|
||||
<Badge variant="outline" className="text-green-600 border-green-200">
|
||||
✓ Resolved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">{incident.monitorUrl}</p>
|
||||
{incident.details && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{incident.details}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatTimeAgo(incident.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-col sm:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${incident.monitorId}`)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
variant={resolvedIds.has(incident.id) ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => toggleResolved(incident.id)}
|
||||
>
|
||||
{resolvedIds.has(incident.id) ? 'Unresolve' : 'Resolve'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
{incidents.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{incidents.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Incidents</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{errorCount}</p>
|
||||
<p className="text-sm text-muted-foreground">Errors</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{changeCount}</p>
|
||||
<p className="text-sm text-muted-foreground">Changes</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter_Tight, Space_Grotesk } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
// Body/UI font - straff, modern, excellent readability
|
||||
const interTight = Inter_Tight({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Headline font - geometric, futuristic, "smart" look
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
}
|
||||
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
|
||||
<body className={interTight.className}>
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,93 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.login(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.login(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Subtle Background Pattern */}
|
||||
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
|
||||
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your Website Monitor account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<div className="mt-1 text-right">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,146 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
return response.snapshots
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = historyData || []
|
||||
const monitor = monitorData
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{monitor?.name || 'Monitor History'}
|
||||
</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h2 className="mb-4 text-xl font-semibold">Check History</h2>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="text-gray-600">No history yet</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
The first check will happen soon
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshots.map((snapshot: any) => (
|
||||
<div
|
||||
key={snapshot.id}
|
||||
className={`rounded-lg bg-white p-4 shadow ${
|
||||
snapshot.changed ? 'border-l-4 border-l-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
snapshot.changed
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{snapshot.changed ? 'Changed' : 'No Change'}
|
||||
</span>
|
||||
{snapshot.error_message && (
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-4 text-sm text-gray-600">
|
||||
<span>HTTP {snapshot.http_status}</span>
|
||||
<span>{snapshot.response_time}ms</span>
|
||||
{snapshot.change_percentage && (
|
||||
<span>{snapshot.change_percentage.toFixed(2)}% changed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.error_message && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{snapshot.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.html_content && (
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/monitors/${id}/snapshot/${snapshot.id}`)
|
||||
}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
return response.snapshots
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading history...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = historyData || []
|
||||
const monitor = monitorData
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')} className="gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{monitor?.name || 'Monitor History'}</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate max-w-lg">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
{monitor && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await monitorAPI.exportAuditTrail(id, 'json');
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await monitorAPI.exportAuditTrail(id, 'csv');
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History List */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">No history yet</h3>
|
||||
<p className="text-muted-foreground">The first check will happen soon</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshots.map((snapshot: any) => {
|
||||
// Determine border color based on HTTP status
|
||||
const getBorderColor = () => {
|
||||
if (snapshot.httpStatus >= 400 || snapshot.errorMessage) {
|
||||
return 'border-l-4 border-l-red-500' // Error (4xx, 5xx)
|
||||
}
|
||||
if (snapshot.httpStatus >= 200 && snapshot.httpStatus < 300) {
|
||||
if (snapshot.changed) {
|
||||
return 'border-l-4 border-l-green-500' // Success with change
|
||||
}
|
||||
return 'border-l-4 border-l-blue-400' // Success no change (neutral)
|
||||
}
|
||||
return 'border-l-4 border-l-blue-400' // Default neutral
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={snapshot.id}
|
||||
className={`transition-all ${getBorderColor()}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{snapshot.changed ? (
|
||||
<Badge variant="default">Changed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Change</Badge>
|
||||
)}
|
||||
{snapshot.errorMessage && (
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(snapshot.createdAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<span>HTTP {snapshot.httpStatus}</span>
|
||||
<span>{snapshot.responseTime}ms</span>
|
||||
{snapshot.changePercentage && (
|
||||
<span>{Number(snapshot.changePercentage).toFixed(2)}% changed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.errorMessage && (
|
||||
<p className="mt-2 text-sm text-destructive">{snapshot.errorMessage}</p>
|
||||
)}
|
||||
|
||||
{snapshot.summary && (
|
||||
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
|
||||
<p className="font-medium text-foreground mb-1">Summary</p>
|
||||
<p>{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${id}/snapshot/${snapshot.id}`)}
|
||||
>
|
||||
{snapshot.errorMessage ? 'View Error' : 'View Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
240
frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx
Normal file
240
frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
|
||||
|
||||
export default function SnapshotDetailsPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const monitorId = params?.id as string
|
||||
const snapshotId = params?.snapshotId as string
|
||||
const [showHtml, setShowHtml] = useState(false)
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(monitorId)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: snapshotData, isLoading } = useQuery({
|
||||
queryKey: ['snapshot', monitorId, snapshotId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.snapshot(monitorId, snapshotId)
|
||||
return response.snapshot
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ['history', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(monitorId, 2)
|
||||
return response.snapshots
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading snapshot...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshot = snapshotData
|
||||
const monitor = monitorData
|
||||
const previousSnapshot = historyData?.find((s: any) =>
|
||||
new Date(s.createdAt) < new Date(snapshot?.createdAt)
|
||||
)
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium">Snapshot not found</p>
|
||||
<Button variant="outline" onClick={() => router.push(`/monitors/${monitorId}`)}>
|
||||
Back to History
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitorId}`)} className="gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to History
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Snapshot Details</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{monitor.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Snapshot Info Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Snapshot Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
{snapshot.changed ? (
|
||||
<Badge variant="default">Changed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Change</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Created At</p>
|
||||
<p className="font-medium">
|
||||
{new Date(snapshot.createdAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">HTTP Status</p>
|
||||
<Badge variant={snapshot.httpStatus >= 400 ? 'destructive' : 'success'}>
|
||||
{snapshot.httpStatus || 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Response Time</p>
|
||||
<p className="font-medium">{snapshot.responseTime}ms</p>
|
||||
</div>
|
||||
|
||||
{snapshot.changePercentage && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Change Percentage</p>
|
||||
<p className="font-medium text-primary">
|
||||
{Number(snapshot.changePercentage).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.errorMessage && (
|
||||
<div className="mt-6 rounded-lg bg-destructive/10 p-4">
|
||||
<p className="text-sm font-medium text-destructive">Error</p>
|
||||
<p className="text-sm text-destructive/80 mt-1">{snapshot.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{snapshot.summary && (
|
||||
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<p className="text-sm font-medium text-blue-900">Change Summary</p>
|
||||
<p className="text-sm text-blue-700 mt-1">{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{snapshot.changed && previousSnapshot && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Changes Detected</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-[500px] overflow-auto rounded-lg border border-border">
|
||||
<ReactDiffViewer
|
||||
oldValue={previousSnapshot.textContent || ''}
|
||||
newValue={snapshot.textContent || ''}
|
||||
splitView={true}
|
||||
compareMethod={DiffMethod.WORDS}
|
||||
useDarkTheme={false}
|
||||
styles={{
|
||||
variables: {
|
||||
light: {
|
||||
diffViewerBackground: 'hsl(40 14% 97%)',
|
||||
addedBackground: 'rgba(34, 197, 94, 0.1)',
|
||||
addedGutterBackground: 'rgba(34, 197, 94, 0.2)',
|
||||
removedBackground: 'rgba(239, 68, 68, 0.1)',
|
||||
removedGutterBackground: 'rgba(239, 68, 68, 0.2)',
|
||||
wordAddedBackground: 'rgba(34, 197, 94, 0.3)',
|
||||
wordRemovedBackground: 'rgba(239, 68, 68, 0.3)',
|
||||
addedGutterColor: '#166534',
|
||||
removedGutterColor: '#991b1b',
|
||||
gutterBackground: 'hsl(35 18% 88%)',
|
||||
gutterBackgroundDark: 'hsl(35 18% 85%)',
|
||||
codeFoldBackground: 'hsl(35 15% 82%)',
|
||||
codeFoldGutterBackground: 'hsl(35 15% 80%)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Text Content when no change */}
|
||||
{!snapshot.changed && snapshot.textContent && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Text Content</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg bg-muted p-4 text-sm whitespace-pre-wrap scrollbar-thin">
|
||||
{snapshot.textContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* HTML Content Toggle */}
|
||||
{snapshot.htmlContent && (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>HTML Content</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowHtml(!showHtml)}>
|
||||
{showHtml ? 'Hide HTML' : 'Show HTML'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{showHtml && (
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg bg-foreground p-4 text-sm text-green-400 whitespace-pre-wrap scrollbar-thin">
|
||||
{snapshot.htmlContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
932
frontend/app/monitors/page.tsx
Normal file
932
frontend/app/monitors/page.tsx
Normal file
@@ -0,0 +1,932 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { VisualSelector } from '@/components/visual-selector'
|
||||
import { monitorTemplates, applyTemplate, MonitorTemplate } from '@/lib/templates'
|
||||
import { Sparkline } from '@/components/sparkline'
|
||||
import { Monitor } from '@/lib/types'
|
||||
import { usePlan } from '@/lib/use-plan'
|
||||
|
||||
|
||||
|
||||
const IGNORE_PRESETS = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Timestamps & Dates', value: 'time, .time, .date, .datetime, .timestamp, .random, .updated, .modified, .posted, .published, [class*="time"], [class*="date"], [class*="timestamp"], [class*="updated"], [class*="modified"]' },
|
||||
{ label: 'Cookie Banners', value: '[id*="cookie"], [class*="cookie"], [id*="consent"], [class*="consent"]' },
|
||||
{ label: 'Social Widgets', value: '.social-share, .twitter-tweet, iframe[src*="youtube"]' },
|
||||
{ label: 'Custom Selector', value: 'custom' },
|
||||
]
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 5, label: 'Every 5 minutes' },
|
||||
{ value: 30, label: 'Every 30 minutes' },
|
||||
{ value: 60, label: 'Every hour' },
|
||||
{ value: 360, label: 'Every 6 hours' },
|
||||
{ value: 1440, label: 'Every 24 hours' },
|
||||
]
|
||||
|
||||
|
||||
|
||||
// Stats card component
|
||||
function StatCard({ icon, label, value, subtext, color }: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string | number
|
||||
subtext?: string
|
||||
color: 'green' | 'amber' | 'red' | 'blue'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
green: {
|
||||
container: 'bg-green-50 text-green-600 border border-green-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
amber: {
|
||||
container: 'bg-amber-50 text-amber-600 border border-amber-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
red: {
|
||||
container: 'bg-red-50 text-red-600 border border-red-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
blue: {
|
||||
container: 'bg-blue-50 text-blue-600 border border-blue-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
}
|
||||
|
||||
const currentColor = colorClasses[color]
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl p-4 ${currentColor.container}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${currentColor.iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs opacity-80">{label}</p>
|
||||
{subtext && <p className="text-xs opacity-60">{subtext}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitorsPage() {
|
||||
const router = useRouter()
|
||||
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [checkingId, setCheckingId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
|
||||
const [newMonitor, setNewMonitor] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: [] as Array<{
|
||||
keyword: string
|
||||
type: 'appears' | 'disappears' | 'count'
|
||||
threshold?: number
|
||||
caseSensitive?: boolean
|
||||
}>,
|
||||
})
|
||||
const [showVisualSelector, setShowVisualSelector] = useState(false)
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const payload: any = {
|
||||
url: newMonitor.url,
|
||||
name: newMonitor.name,
|
||||
frequency: newMonitor.frequency,
|
||||
}
|
||||
if (newMonitor.ignoreSelector) {
|
||||
payload.ignoreRules = [{ type: 'css', value: newMonitor.ignoreSelector }]
|
||||
}
|
||||
if (newMonitor.keywordRules.length > 0) {
|
||||
payload.keywordRules = newMonitor.keywordRules
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await monitorAPI.update(editingId, payload)
|
||||
toast.success('Monitor updated successfully')
|
||||
} else {
|
||||
await monitorAPI.create(payload)
|
||||
toast.success('Monitor created successfully')
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
})
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
refetch()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save monitor:', err)
|
||||
toast.error(err.response?.data?.message || 'Failed to save monitor')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (monitor: any) => {
|
||||
let selectedPreset = ''
|
||||
let ignoreSelector = ''
|
||||
|
||||
if (monitor.ignoreRules && monitor.ignoreRules.length > 0) {
|
||||
const ruleValue = monitor.ignoreRules[0].value
|
||||
const matchingPreset = IGNORE_PRESETS.find(p => p.value === ruleValue)
|
||||
if (matchingPreset) {
|
||||
selectedPreset = ruleValue
|
||||
ignoreSelector = ruleValue
|
||||
} else {
|
||||
selectedPreset = 'custom'
|
||||
ignoreSelector = ruleValue
|
||||
}
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: monitor.url,
|
||||
name: monitor.name || '',
|
||||
frequency: monitor.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitor.keywordRules || []
|
||||
})
|
||||
setEditingId(monitor.id)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
setNewMonitor({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectTemplate = (template: MonitorTemplate) => {
|
||||
const monitorData = applyTemplate(template, template.urlPlaceholder)
|
||||
// Convert ignoreRules to format expected by form
|
||||
let ignoreSelector = ''
|
||||
let selectedPreset = ''
|
||||
|
||||
if (monitorData.ignoreRules && monitorData.ignoreRules.length > 0) {
|
||||
// Use first rule for now as form supports single selector
|
||||
const rule = monitorData.ignoreRules[0]
|
||||
if (rule.type === 'css') {
|
||||
ignoreSelector = rule.value
|
||||
selectedPreset = 'custom'
|
||||
|
||||
// Check if matches preset
|
||||
const preset = IGNORE_PRESETS.find(p => p.value === rule.value)
|
||||
if (preset) selectedPreset = preset.value
|
||||
}
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: monitorData.url,
|
||||
name: monitorData.name,
|
||||
frequency: monitorData.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitorData.keywordRules as any[]
|
||||
})
|
||||
setShowTemplates(false)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
// Prevent multiple simultaneous checks
|
||||
if (checkingId !== null) return
|
||||
|
||||
setCheckingId(id)
|
||||
try {
|
||||
const result = await monitorAPI.check(id)
|
||||
if (result.snapshot?.errorMessage) {
|
||||
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
|
||||
} else if (result.snapshot?.changed) {
|
||||
toast.success('Changes detected!', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => router.push(`/monitors/${id}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.info('No changes detected')
|
||||
}
|
||||
refetch()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
toast.error(err.response?.data?.message || 'Failed to check monitor')
|
||||
} finally {
|
||||
setCheckingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this monitor?')) return
|
||||
|
||||
try {
|
||||
await monitorAPI.delete(id)
|
||||
toast.success('Monitor deleted')
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete monitor:', err)
|
||||
toast.error('Failed to delete monitor')
|
||||
}
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const filteredMonitors = useMemo(() => {
|
||||
if (filterStatus === 'all') return monitors
|
||||
return monitors.filter((m: any) => m.status === filterStatus)
|
||||
}, [monitors, filterStatus])
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => {
|
||||
const total = monitors.length
|
||||
const active = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const avgUptime = total > 0 ? ((active / total) * 100).toFixed(1) : '0'
|
||||
return { total, active, errors, avgUptime }
|
||||
}, [monitors])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
{/* Stats Overview */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>}
|
||||
label="Total Monitors"
|
||||
value={stats.total}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
subtext="Running smoothly"
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>}
|
||||
label="Errors"
|
||||
value={stats.errors}
|
||||
subtext="Needs attention"
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>}
|
||||
label="Avg Uptime"
|
||||
value={`${stats.avgUptime}%`}
|
||||
subtext="Last 30 days"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Template Selection Modal */}
|
||||
{
|
||||
showTemplates && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<Card className="max-h-[85vh] w-full max-w-4xl overflow-y-auto">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Choose a Template</CardTitle>
|
||||
<CardDescription>Start with a pre-configured monitor setup</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)}>✕</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{monitorTemplates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
className="flex flex-col items-start gap-2 rounded-lg border p-4 text-left shadow-sm transition-all hover:border-primary hover:bg-primary/5 hover:shadow-md"
|
||||
>
|
||||
<span className="text-2xl">{template.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{template.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Visual Selector Modal */}
|
||||
{
|
||||
showVisualSelector && (
|
||||
<VisualSelector
|
||||
url={newMonitor.url}
|
||||
onSelect={(selector) => {
|
||||
setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' })
|
||||
setShowVisualSelector(false)
|
||||
}}
|
||||
onClose={() => setShowVisualSelector(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
{(['all', 'active', 'error'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${filterStatus === status
|
||||
? 'bg-white text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'active' ? 'Active' : 'Errors'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'list' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="List view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowTemplates(true)}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
Templates
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Monitor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add/Edit Monitor Form */}
|
||||
{
|
||||
showAddForm && (
|
||||
<Card className="mb-6 animate-fade-in border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? 'Edit Monitor' : 'Add New Monitor'}</CardTitle>
|
||||
<CardDescription>
|
||||
{editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Name (optional)"
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, name: e.target.value })}
|
||||
placeholder="My Monitor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Check Frequency"
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, frequency: parseInt(e.target.value) })}
|
||||
options={FREQUENCY_OPTIONS.map(opt => ({
|
||||
...opt,
|
||||
disabled: opt.value < minFrequency,
|
||||
label: opt.value < minFrequency ? `${opt.label} (Pro)` : opt.label
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Ignore Content"
|
||||
value={newMonitor.selectedPreset}
|
||||
onChange={(e) => {
|
||||
const preset = e.target.value
|
||||
if (preset === 'custom') {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: '' })
|
||||
} else {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: preset })
|
||||
}
|
||||
}}
|
||||
options={IGNORE_PRESETS.map(p => ({ value: p.value, label: p.label }))}
|
||||
hint="Ignore dynamic content like timestamps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newMonitor.selectedPreset === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label="Custom CSS Selector"
|
||||
type="text"
|
||||
value={newMonitor.ignoreSelector}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, ignoreSelector: e.target.value })}
|
||||
placeholder="e.g. .ad-banner, #timestamp"
|
||||
hint="Elements matching this selector will be ignored"
|
||||
/>
|
||||
{newMonitor.url && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowVisualSelector(true)}
|
||||
>
|
||||
🎯 Use Visual Selector
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Alerts Section */}
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">Keyword Alerts</h4>
|
||||
<p className="text-xs text-muted-foreground">Get notified when specific keywords appear or disappear</p>
|
||||
</div>
|
||||
{canUseKeywords && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
keywordRules: [...newMonitor.keywordRules, { keyword: '', type: 'appears', caseSensitive: false }]
|
||||
})
|
||||
}}
|
||||
>
|
||||
+ Add Keyword
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canUseKeywords ? (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-2 rounded-full bg-muted p-2">
|
||||
<svg className="h-6 w-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-sm">Pro Feature</p>
|
||||
<p className="text-xs text-muted-foreground">Upgrade to Pro to track specific keywords and content changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{newMonitor.keywordRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click "Add Keyword" to create one.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newMonitor.keywordRules.map((rule, index) => (
|
||||
<div key={index} className="grid gap-2 rounded-md border bg-card p-3 sm:grid-cols-12">
|
||||
<div className="sm:col-span-4">
|
||||
<Input
|
||||
label=""
|
||||
type="text"
|
||||
value={rule.keyword}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].keyword = e.target.value
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="e.g. hiring, sold out"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Select
|
||||
label=""
|
||||
value={rule.type}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].type = e.target.value as any
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
options={[
|
||||
{ value: 'appears', label: 'Appears' },
|
||||
{ value: 'disappears', label: 'Disappears' },
|
||||
{ value: 'count', label: 'Count changes' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{rule.type === 'count' && (
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
value={rule.threshold || 1}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].threshold = parseInt(e.target.value)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="Threshold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 sm:col-span-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.caseSensitive || false}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].caseSensitive = e.target.checked
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Case
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end sm:col-span-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = newMonitor.keywordRules.filter((_, i) => i !== index)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded p-1 text-red-500 hover:bg-red-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">
|
||||
{editingId ? 'Save Changes' : 'Create Monitor'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card >
|
||||
)
|
||||
}
|
||||
|
||||
{/* Monitors Grid/List */}
|
||||
{
|
||||
filteredMonitors.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}
|
||||
</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{filterStatus === 'all'
|
||||
? 'Start monitoring your first website to get notified of changes'
|
||||
: 'Try changing the filter to see other monitors'}
|
||||
</p>
|
||||
{filterStatus === 'all' && (
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
Create Your First Monitor
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredMonitors.map((monitor: any) => (
|
||||
<Card
|
||||
key={monitor.id}
|
||||
hover
|
||||
className="group animate-fade-in overflow-hidden"
|
||||
>
|
||||
<div className={`h-1.5 ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-300'}`} />
|
||||
<CardContent className="p-5">
|
||||
{/* Monitor Info */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-muted-foreground">Frequency</p>
|
||||
</div>
|
||||
<div>
|
||||
{monitor.last_changed_at ? (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">
|
||||
{new Date(monitor.last_changed_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">-</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Checked */}
|
||||
{monitor.last_checked_at ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Not checked yet
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
|
||||
"{monitor.recentSnapshots[0].summary}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sparkline & Importance */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
|
||||
<div className="mb-4 flex items-end justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Response Time</p>
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={30}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Importance</p>
|
||||
<Badge variant="outline" className={`${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
|
||||
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
||||
'border-slate-200 bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{monitor.recentSnapshots[0].importanceScore || 0}/100
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
title="View History"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(monitor)}
|
||||
title="Edit Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
title="Delete Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="space-y-3">
|
||||
{filteredMonitors.map((monitor: any) => {
|
||||
return (
|
||||
<Card key={monitor.id} hover className="animate-fade-in">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status Indicator */}
|
||||
<div className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center ${monitor.status === 'active' ? 'bg-green-100' : monitor.status === 'error' ? 'bg-red-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<div className={`h-3 w-3 rounded-full ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Monitor Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground mr-4">
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-xs">Frequency</p>
|
||||
</div>
|
||||
<div className="text-center w-24">
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? (
|
||||
<Badge variant="outline" className={`w-full justify-center ${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
|
||||
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
||||
'border-slate-200 bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{monitor.recentSnapshots[0].importanceScore}/100
|
||||
</Badge>
|
||||
) : (
|
||||
<p className="font-medium text-foreground">-</p>
|
||||
)}
|
||||
<p className="text-xs mt-1">Importance</p>
|
||||
</div>
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
|
||||
<div className="w-24">
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={24}
|
||||
width={96}
|
||||
/>
|
||||
<p className="text-xs text-center mt-1">Response Time</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
<p className="text-xs">Last Change</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitor.id}`)}>
|
||||
History
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</DashboardLayout >
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,434 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Website Monitor</h1>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HeroSection, UseCaseShowcase, HowItWorks, Differentiators, SocialProof, FinalCTA } from '@/components/landing/LandingSections'
|
||||
import { LiveStatsBar } from '@/components/landing/LiveStatsBar'
|
||||
import { PricingComparison } from '@/components/landing/PricingComparison'
|
||||
import { SectionDivider } from '@/components/landing/MagneticElements'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
|
||||
|
||||
export default function Home() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isAuth, setIsAuth] = useState(false)
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
||||
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// Check auth status but DO NOT redirect
|
||||
const auth = isAuthenticated()
|
||||
setIsAuth(auth)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Scroll progress tracking
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const totalScroll = document.documentElement.scrollHeight - window.innerHeight
|
||||
const progress = totalScroll > 0 ? (window.scrollY / totalScroll) * 100 : 0
|
||||
setScrollProgress(progress)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'What is website monitoring?',
|
||||
answer: 'Website monitoring is the process of testing and verifying that end-users can interact with a website or web application as expected. It continuously checks your website for changes, downtime, or performance issues.'
|
||||
},
|
||||
{
|
||||
question: 'How fast are the alerts?',
|
||||
answer: 'Our alerts are sent within seconds of detecting a change. You can configure notifications via email, webhook, Slack, or other integrations.'
|
||||
},
|
||||
{
|
||||
question: 'Can I monitor SSL certificates?',
|
||||
answer: 'Yes! We automatically monitor SSL certificate expiration and will alert you before your certificate expires.'
|
||||
},
|
||||
{
|
||||
question: 'Do you offer a free trial?',
|
||||
answer: 'Yes, we offer a free Starter plan that includes 3 monitors with hourly checks. No credit card required.'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/20 selection:text-primary">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 transition-all duration-200">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary transition-transform group-hover:scale-110 shadow-lg shadow-primary/20">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
|
||||
<Link href="#pricing" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Pricing</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuth ? (
|
||||
<Link href="/dashboard">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/register">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="md:hidden border-t border-border bg-background px-6 py-4 shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link href="#features" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features</Link>
|
||||
<Link href="#pricing" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Pricing</Link>
|
||||
{!isAuth && (
|
||||
<>
|
||||
<Link href="/register" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-primary font-bold">Get Started</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header >
|
||||
|
||||
{/* Scroll Progress Indicator */}
|
||||
<motion.div
|
||||
className="fixed top-16 left-0 right-0 h-1 bg-[hsl(var(--teal))] z-50 origin-left"
|
||||
style={{ scaleX: scrollProgress / 100 }}
|
||||
initial={{ scaleX: 0 }}
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<HeroSection isAuthenticated={isAuth} />
|
||||
|
||||
{/* Live Stats Bar */}
|
||||
<LiveStatsBar />
|
||||
|
||||
{/* Use Case Showcase */}
|
||||
<UseCaseShowcase />
|
||||
|
||||
{/* Section Divider: Use Cases -> How It Works */}
|
||||
<SectionDivider variant="wave" toColor="section-bg-4" />
|
||||
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Differentiators */}
|
||||
<Differentiators />
|
||||
|
||||
{/* Section Divider: Differentiators -> Pricing */}
|
||||
<SectionDivider variant="curve" toColor="section-bg-6" />
|
||||
|
||||
{/* Pricing Comparison */}
|
||||
<PricingComparison />
|
||||
|
||||
{/* Social Proof */}
|
||||
<SocialProof />
|
||||
|
||||
{/* Pricing Section */}
|
||||
< section id="pricing" className="border-t border-border/40 bg-[hsl(var(--section-bg-2))] py-24" >
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold sm:text-4xl text-foreground">
|
||||
Simple pricing, no hidden fees
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground">
|
||||
Start for free and scale as you grow. Change plans anytime.
|
||||
</p>
|
||||
<div className="inline-flex items-center rounded-full bg-background p-1.5 shadow-sm border border-border">
|
||||
<button
|
||||
onClick={() => setBillingPeriod('monthly')}
|
||||
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'monthly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingPeriod('yearly')}
|
||||
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'yearly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Yearly <span className="ml-1 text-[10px] opacity-80">(Save 20%)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
|
||||
{/* Starter Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-primary/20 transition-all"
|
||||
>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Starter</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Perfect for side projects</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">$0</span>
|
||||
<span className="text-muted-foreground ml-2">/mo</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
3 monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Hourly checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Email alerts
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative rounded-3xl border-2 border-primary bg-card p-8 shadow-2xl shadow-primary/10 z-10 scale-105"
|
||||
>
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-bold text-primary-foreground shadow-lg">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Pro</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">For serious businesses</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">${billingPeriod === 'monthly' ? '29' : '24'}</span>
|
||||
<span className="text-muted-foreground ml-2">/mo</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
50 monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
1-minute checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
All alert channels (Slack/SMS)
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
SSL monitoring
|
||||
</li>
|
||||
</ul>
|
||||
<Button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl h-11 shadow-lg shadow-primary/20 font-semibold">
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-border transition-all"
|
||||
>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Enterprise</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Custom solutions</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-4xl font-bold tracking-tight text-foreground">Custom</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Unlimited monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
30-second checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
SSO & SAML
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Dedicated support
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* FAQ Section */}
|
||||
< section id="faq" className="border-t border-border/40 py-24 bg-background" >
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold sm:text-4xl text-foreground">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="rounded-2xl border border-border bg-card overflow-hidden"
|
||||
initial={false}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||
className="flex w-full items-center justify-between p-6 text-left hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.question}</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{openFaq === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="border-t border-border px-6 pb-6 pt-4 text-muted-foreground bg-secondary/5"
|
||||
>
|
||||
{faq.answer}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* Final CTA */}
|
||||
<FinalCTA isAuthenticated={isAuth} />
|
||||
|
||||
{/* Footer */}
|
||||
< footer className="border-t border-border bg-background py-12 text-sm" >
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">MonitorTool</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-xs mb-6">
|
||||
The modern platform for uptime monitoring, change detection, and performance tracking.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Social icons placeholders */}
|
||||
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#features" className="hover:text-primary transition-colors">Features</Link></li>
|
||||
<li><Link href="#pricing" className="hover:text-primary transition-colors">Pricing</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">About</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Blog</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Careers</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Contact</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Privacy</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Terms</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Cookie Policy</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
||||
<p>© 2026 MonitorTool. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,129 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.register(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to register'
|
||||
const details = err.response?.data?.details
|
||||
|
||||
if (details && Array.isArray(details)) {
|
||||
setError(details.join(', '))
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Create Account</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters with uppercase, lowercase, and number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.register(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to register'
|
||||
const details = err.response?.data?.details
|
||||
|
||||
if (details && Array.isArray(details)) {
|
||||
setError(details.join(', '))
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Subtle Background Pattern */}
|
||||
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
|
||||
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
|
||||
<CardDescription>
|
||||
Start monitoring your websites for changes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
hint="At least 8 characters with uppercase, lowercase, and number"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
192
frontend/app/reset-password/[token]/page.tsx
Normal file
192
frontend/app/reset-password/[token]/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Client-side validation
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
toast.error('Password must contain at least one uppercase letter')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
toast.error('Password must contain at least one lowercase letter')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
toast.error('Password must contain at least one number')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await authAPI.resetPassword(token, password)
|
||||
setSuccess(true)
|
||||
toast.success('Password reset successfully!')
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
console.error('Reset password error:', error)
|
||||
const message = error.response?.data?.message || 'Failed to reset password. The link may have expired.'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set New Password</CardTitle>
|
||||
<CardDescription>
|
||||
{success
|
||||
? 'Your password has been reset'
|
||||
: 'Choose a strong password for your account'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!success ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
hint="At least 8 characters, including uppercase, lowercase, and number"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-xs">
|
||||
<p className="mb-2 font-medium">Password must contain:</p>
|
||||
<ul className="space-y-1">
|
||||
<li className={password.length >= 8 ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{password.length >= 8 ? '✓' : '○'} At least 8 characters
|
||||
</li>
|
||||
<li className={/[A-Z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[A-Z]/.test(password) ? '✓' : '○'} One uppercase letter
|
||||
</li>
|
||||
<li className={/[a-z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[a-z]/.test(password) ? '✓' : '○'} One lowercase letter
|
||||
</li>
|
||||
<li className={/[0-9]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[0-9]/.test(password) ? '✓' : '○'} One number
|
||||
</li>
|
||||
<li className={password === confirmPassword && password.length > 0 ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{password === confirmPassword && password.length > 0 ? '✓' : '○'} Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Password Reset Successfully!</p>
|
||||
<p className="mt-1 text-sm text-green-700">
|
||||
You can now log in with your new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
471
frontend/app/settings/page.tsx
Normal file
471
frontend/app/settings/page.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { settingsAPI } from '@/lib/api'
|
||||
import { clearAuth } from '@/lib/auth'
|
||||
import { usePlan } from '@/lib/use-plan'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false)
|
||||
const [showWebhookForm, setShowWebhookForm] = useState(false)
|
||||
const [showSlackForm, setShowSlackForm] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const { canUseSlack, canUseWebhook } = usePlan()
|
||||
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState('')
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
|
||||
// Fetch user settings
|
||||
const { data: settings, isLoading, refetch } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await settingsAPI.get()
|
||||
setWebhookUrl(response.settings.webhookUrl || '')
|
||||
setSlackWebhookUrl(response.settings.slackWebhookUrl || '')
|
||||
return response.settings
|
||||
},
|
||||
})
|
||||
|
||||
// Change password mutation
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
throw new Error('Passwords do not match')
|
||||
}
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters')
|
||||
}
|
||||
return settingsAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully')
|
||||
setShowPasswordForm(false)
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || error.message || 'Failed to change password')
|
||||
},
|
||||
})
|
||||
|
||||
// Toggle email notifications
|
||||
const toggleEmailMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
return settingsAPI.updateNotifications({ emailEnabled: enabled })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Email notifications updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update notifications')
|
||||
},
|
||||
})
|
||||
|
||||
// Update webhook
|
||||
const updateWebhookMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.updateNotifications({
|
||||
webhookUrl: webhookUrl || null,
|
||||
webhookEnabled: !!webhookUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Webhook settings updated')
|
||||
setShowWebhookForm(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update webhook')
|
||||
},
|
||||
})
|
||||
|
||||
// Update Slack
|
||||
const updateSlackMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.updateNotifications({
|
||||
slackWebhookUrl: slackWebhookUrl || null,
|
||||
slackEnabled: !!slackWebhookUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Slack integration updated')
|
||||
setShowSlackForm(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update Slack')
|
||||
},
|
||||
})
|
||||
|
||||
// Delete account mutation
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.deleteAccount(deletePassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Account deleted successfully')
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to delete account')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Settings" description="Manage your account and preferences">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Settings" description="Manage your account and preferences">
|
||||
{/* Account Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{settings?.plan || 'free'}</Badge>
|
||||
<span className="text-sm text-muted-foreground">plan</span>
|
||||
</div>
|
||||
|
||||
{!showPasswordForm ? (
|
||||
<Button variant="outline" onClick={() => setShowPasswordForm(true)}>
|
||||
Change Password
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
hint="At least 8 characters"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => changePasswordMutation.mutate()}
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? 'Saving...' : 'Save Password'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false)
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Configure how you receive alerts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Email Notifications */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium">Email Notifications</p>
|
||||
<p className="text-sm text-muted-foreground">Receive email alerts when changes are detected</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings?.emailEnabled !== false ? 'success' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => toggleEmailMutation.mutate(settings?.emailEnabled === false)}
|
||||
disabled={toggleEmailMutation.isPending}
|
||||
>
|
||||
{settings?.emailEnabled !== false ? 'Enabled' : 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Slack Integration */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Slack Integration</p>
|
||||
<p className="text-sm text-muted-foreground">Send alerts to your Slack workspace</p>
|
||||
{settings?.slackEnabled && (
|
||||
<p className="mt-1 text-xs text-green-600">✓ Configured</p>
|
||||
)}
|
||||
{!canUseSlack && (
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
|
||||
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSlackForm(!showSlackForm)}
|
||||
disabled={!canUseSlack}
|
||||
>
|
||||
{settings?.slackEnabled ? 'Reconfigure' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSlackForm && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
type="url"
|
||||
value={slackWebhookUrl}
|
||||
onChange={(e) => setSlackWebhookUrl(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
hint="Get this from your Slack app settings"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => updateSlackMutation.mutate()}
|
||||
disabled={updateSlackMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{updateSlackMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowSlackForm(false)}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{settings?.slackEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setSlackWebhookUrl('')
|
||||
updateSlackMutation.mutate()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webhook */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Webhook</p>
|
||||
<p className="text-sm text-muted-foreground">Send JSON payloads to your server</p>
|
||||
{settings?.webhookEnabled && (
|
||||
<p className="mt-1 text-xs text-green-600">✓ Configured</p>
|
||||
)}
|
||||
{!canUseWebhook && (
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
|
||||
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowWebhookForm(!showWebhookForm)}
|
||||
disabled={!canUseWebhook}
|
||||
>
|
||||
{settings?.webhookEnabled ? 'Reconfigure' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showWebhookForm && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
<Input
|
||||
label="Webhook URL"
|
||||
type="url"
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://your-server.com/webhook"
|
||||
hint="We'll POST JSON data to this URL on changes"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => updateWebhookMutation.mutate()}
|
||||
disabled={updateWebhookMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{updateWebhookMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowWebhookForm(false)}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{settings?.webhookEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setWebhookUrl('')
|
||||
updateWebhookMutation.mutate()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan & Billing */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Plan & Billing</CardTitle>
|
||||
<CardDescription>Manage your subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-lg font-bold capitalize">{settings?.plan || 'Free'} Plan</p>
|
||||
<Badge>Current</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{settings?.plan === 'free' && '5 monitors, 1hr frequency'}
|
||||
{settings?.plan === 'pro' && '50 monitors, 5min frequency'}
|
||||
{settings?.plan === 'business' && '200 monitors, 1min frequency'}
|
||||
{settings?.plan === 'enterprise' && 'Unlimited monitors, all features'}
|
||||
</p>
|
||||
{settings?.plan !== 'free' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Stripe Customer ID: {settings?.stripeCustomerId || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
Manage Plan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>Irreversible actions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showDeleteConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete Account</p>
|
||||
<p className="text-sm text-muted-foreground">Permanently delete your account and all data</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="mb-2">
|
||||
<p className="font-semibold text-destructive">⚠️ This action cannot be undone!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All your monitors, snapshots, and alerts will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm with your password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteAccountMutation.mutate()}
|
||||
disabled={!deletePassword || deleteAccountMutation.isPending}
|
||||
>
|
||||
{deleteAccountMutation.isPending ? 'Deleting...' : 'Yes, Delete My Account'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeletePassword('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
150
frontend/app/verify-email/[token]/page.tsx
Normal file
150
frontend/app/verify-email/[token]/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
|
||||
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
const response = await authAPI.verifyEmail(token)
|
||||
setStatus('success')
|
||||
setMessage(response.message || 'Email verified successfully!')
|
||||
toast.success('Email verified successfully!')
|
||||
|
||||
// Redirect to dashboard after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard')
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
setStatus('error')
|
||||
const errorMessage = error.response?.data?.message || 'Failed to verify email. The link may have expired.'
|
||||
setMessage(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
verifyEmail()
|
||||
}
|
||||
}, [token, router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Verification</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'verifying' && 'Verifying your email address...'}
|
||||
{status === 'success' && 'Your email has been verified'}
|
||||
{status === 'error' && 'Verification failed'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'verifying' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">Please wait...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Email Verified!</p>
|
||||
<p className="mt-1 text-sm text-green-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Redirecting to dashboard...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-red-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-red-900">Verification Failed</p>
|
||||
<p className="mt-1 text-sm text-red-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">Possible reasons:</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>The verification link has expired (24 hours)</li>
|
||||
<li>The link was already used</li>
|
||||
<li>The link is invalid</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push('/register')}
|
||||
>
|
||||
Register Again
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
frontend/components/landing/CompetitorDemoVisual.tsx
Normal file
138
frontend/components/landing/CompetitorDemoVisual.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Bell, ArrowDown } from 'lucide-react'
|
||||
|
||||
export function CompetitorDemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--primary))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* Browser Header */}
|
||||
<div className="mb-3 flex items-center gap-2 px-2 py-1.5 rounded-md bg-secondary/50 border border-border">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground font-mono">
|
||||
competitor.com/pricing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Table */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-foreground">Professional Plan</h4>
|
||||
|
||||
{/* Price Card */}
|
||||
<motion.div
|
||||
className="p-4 rounded-xl border-2 bg-white relative overflow-hidden"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Shine effect on change */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '200%' }}
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/50 to-transparent"
|
||||
style={{ transform: 'skewX(-20deg)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 space-y-2">
|
||||
{/* Old Price */}
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: phase === 1 ? 0.4 : 1,
|
||||
scale: phase === 1 ? 0.95 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<motion.span
|
||||
className="text-3xl font-bold"
|
||||
animate={{
|
||||
textDecoration: phase === 1 ? 'line-through' : 'none',
|
||||
color: phase === 1 ? 'hsl(var(--muted-foreground))' : 'hsl(var(--foreground))'
|
||||
}}
|
||||
>
|
||||
$99
|
||||
</motion.span>
|
||||
<span className="text-sm text-muted-foreground">/month</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* New Price with animated arrow */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-[hsl(var(--teal))]">
|
||||
$79
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/month</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Savings Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8, rotate: -5 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.4, type: 'spring' }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<span className="text-[9px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
|
||||
Save $240/year
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Alert Notification */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
<motion.span
|
||||
animate={{ scale: [1, 1.3, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
|
||||
Alert sent to your team
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
915
frontend/components/landing/LandingSections.tsx
Normal file
915
frontend/components/landing/LandingSections.tsx
Normal file
@@ -0,0 +1,915 @@
|
||||
'use client'
|
||||
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Check, ArrowRight, Shield, Search, FileCheck, TrendingUp,
|
||||
Target, Filter, Bell, Eye, Slack, Webhook, History,
|
||||
Zap, Lock, ChevronRight, Star
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SEODemoVisual } from './SEODemoVisual'
|
||||
import { CompetitorDemoVisual } from './CompetitorDemoVisual'
|
||||
import { PolicyDemoVisual } from './PolicyDemoVisual'
|
||||
import { WaitlistForm } from './WaitlistForm'
|
||||
import { MagneticButton, SectionDivider } from './MagneticElements'
|
||||
|
||||
// Animation Variants
|
||||
const fadeInUp: Variants = {
|
||||
hidden: { opacity: 0, y: 30, filter: 'blur(4px)' },
|
||||
visible: (i: number = 0) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
delay: i * 0.15,
|
||||
duration: 0.7,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scaleIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 1. HERO SECTION - "Track competitor changes without the noise"
|
||||
// ============================================
|
||||
export function HeroSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-32 pb-24 lg:pt-40 lg:pb-32 bg-[hsl(var(--section-bg-1))]">
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 grain-texture" />
|
||||
<div className="absolute right-0 top-20 -z-10 h-[600px] w-[600px] rounded-full bg-[hsl(var(--primary))] opacity-8 blur-[120px]" />
|
||||
<div className="absolute left-0 bottom-0 -z-10 h-[400px] w-[400px] rounded-full bg-[hsl(var(--teal))] opacity-8 blur-[100px]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
<div className="grid lg:grid-cols-[60%_40%] gap-16 items-center">
|
||||
{/* Left: Content */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col gap-8"
|
||||
>
|
||||
{/* Overline */}
|
||||
<motion.div variants={fadeInUp} custom={0}>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))]">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--teal))] opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[hsl(var(--teal))]"></span>
|
||||
</span>
|
||||
For SEO & Growth Teams
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hero Headline */}
|
||||
<motion.h1
|
||||
variants={fadeInUp}
|
||||
custom={1}
|
||||
className="text-5xl lg:text-7xl font-display font-bold leading-[1.08] tracking-tight text-foreground"
|
||||
>
|
||||
Track competitor changes{' '}
|
||||
<span className="text-[hsl(var(--primary))]">without the noise.</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p
|
||||
variants={fadeInUp}
|
||||
custom={2}
|
||||
className="text-xl lg:text-2xl text-muted-foreground font-body leading-relaxed max-w-2xl"
|
||||
>
|
||||
Less noise. More signal. Proof included.
|
||||
</motion.p>
|
||||
|
||||
{/* Feature Bullets */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={3}
|
||||
className="grid md:grid-cols-2 gap-4 max-w-2xl"
|
||||
>
|
||||
{[
|
||||
'Auto-filter cookie banners & timestamps',
|
||||
'Keyword alerts when it matters',
|
||||
'Slack/Webhook integration',
|
||||
'Audit-proof history & snapshots'
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-[hsl(var(--teal))]/20">
|
||||
<Check className="h-3 w-3 text-[hsl(var(--teal))]" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="text-foreground text-sm font-medium leading-tight">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={4}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<MagneticButton strength={0.2}>
|
||||
<Link href={isAuthenticated ? "/dashboard" : "/register"}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--primary))] px-8 text-white hover:bg-[hsl(var(--primary))]/90 shadow-2xl shadow-[hsl(var(--primary))]/25 transition-all hover:scale-105 hover:-translate-y-0.5 font-semibold text-base group"
|
||||
>
|
||||
{isAuthenticated ? 'Go to Dashboard' : 'Get Started Free'}
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust Signals */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={5}
|
||||
className="flex flex-wrap items-center gap-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" />
|
||||
<span>No credit card</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>No spam, ever</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span>Early access bonus</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: Animated Visual - Noise → Signal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.3 }}
|
||||
className="relative"
|
||||
>
|
||||
<NoiseToSignalVisual />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Noise → Signal Animation Component - Enhanced
|
||||
function NoiseToSignalVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [particles, setParticles] = useState<{ id: number; x: number; y: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) return
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => {
|
||||
const nextPhase = (p + 1) % 4
|
||||
// Trigger particles when transitioning from phase 0 to 1
|
||||
if (p === 0 && nextPhase === 1) {
|
||||
triggerParticles()
|
||||
}
|
||||
return nextPhase
|
||||
})
|
||||
}, 2500)
|
||||
return () => clearInterval(interval)
|
||||
}, [isPaused])
|
||||
|
||||
const triggerParticles = () => {
|
||||
const newParticles = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: Date.now() + i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100
|
||||
}))
|
||||
setParticles(newParticles)
|
||||
setTimeout(() => setParticles([]), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative aspect-[4/3] rounded-3xl border-2 border-border bg-card/50 backdrop-blur-sm shadow-2xl overflow-hidden cursor-pointer group"
|
||||
style={{ perspective: '1000px' }}
|
||||
whileHover={{ rotateY: 2, rotateX: -2, scale: 1.02 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onHoverStart={() => setIsPaused(true)}
|
||||
onHoverEnd={() => setIsPaused(false)}
|
||||
>
|
||||
{/* Pulsing Glow Border */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-3xl"
|
||||
animate={{
|
||||
boxShadow: [
|
||||
'0 0 0px hsl(var(--teal))',
|
||||
'0 0 20px hsl(var(--teal) / 0.5)',
|
||||
'0 0 0px hsl(var(--teal))'
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Particles */}
|
||||
{particles.map(particle => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
className="absolute w-1 h-1 rounded-full bg-[hsl(var(--teal))]"
|
||||
initial={{ x: `${particle.x}%`, y: `${particle.y}%`, opacity: 1, scale: 1 }}
|
||||
animate={{
|
||||
y: `${particle.y - 20}%`,
|
||||
opacity: 0,
|
||||
scale: 0
|
||||
}}
|
||||
transition={{ duration: 0.8 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Mock Browser Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 mx-4 px-3 py-1 rounded-md bg-background/50 text-xs text-muted-foreground font-mono text-center">
|
||||
competitor-site.com/pricing
|
||||
</div>
|
||||
{isPaused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-[10px] text-muted-foreground font-medium"
|
||||
>
|
||||
PAUSED
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-8 space-y-4 relative">
|
||||
{/* Noise Counter */}
|
||||
<motion.div
|
||||
className="absolute top-4 left-4 px-3 py-1 rounded-full bg-background/80 backdrop-blur-sm border border-border text-xs font-mono font-semibold"
|
||||
animate={{
|
||||
opacity: phase === 0 ? 1 : 0.5,
|
||||
scale: phase === 0 ? 1 : 0.95
|
||||
}}
|
||||
>
|
||||
Noise: {phase === 0 ? '67%' : '0%'}
|
||||
</motion.div>
|
||||
|
||||
{/* Phase 0: Noisy Page */}
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: phase === 0 ? 1 : 0,
|
||||
scale: phase === 0 ? 1 : 0.98,
|
||||
filter: phase === 0 ? 'blur(0px)' : 'blur(8px)'
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Cookie Banner - with strikethrough */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
|
||||
animate={{
|
||||
x: phase >= 1 ? -10 : 0,
|
||||
opacity: phase >= 1 ? 0.3 : 1
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">🍪 Cookie Banner</span>
|
||||
<span className="text-xs text-red-500 font-semibold">
|
||||
NOISE
|
||||
</span>
|
||||
{/* Strikethrough animation */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise Plan Card */}
|
||||
<div className="p-4 rounded-lg bg-background border border-border">
|
||||
<p className="text-sm font-semibold text-foreground mb-2">Enterprise Plan</p>
|
||||
<p className="text-2xl font-bold text-[hsl(var(--burgundy))]">$99/mo</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamp - with strikethrough */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
|
||||
animate={{
|
||||
x: phase >= 1 ? -10 : 0,
|
||||
opacity: phase >= 1 ? 0.3 : 1
|
||||
}}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">⏰ Last updated: 10:23 AM</span>
|
||||
<span className="text-xs text-red-500 font-semibold">
|
||||
NOISE
|
||||
</span>
|
||||
{/* Strikethrough animation */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Phase 1-3: Filtered + Highlighted Signal */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.85, rotateX: -15 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateX: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
scale: { type: 'spring', stiffness: 300, damping: 20 }
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center p-8"
|
||||
>
|
||||
<motion.div
|
||||
className="w-full p-6 rounded-2xl bg-white border-2 border-[hsl(var(--teal))] shadow-2xl relative overflow-hidden"
|
||||
animate={{
|
||||
boxShadow: [
|
||||
'0 20px 60px hsl(var(--teal) / 0.2)',
|
||||
'0 20px 80px hsl(var(--teal) / 0.3)',
|
||||
'0 20px 60px hsl(var(--teal) / 0.2)'
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
{/* Animated corner accent */}
|
||||
<motion.div
|
||||
className="absolute top-0 right-0 w-20 h-20 bg-[hsl(var(--teal))]/10 rounded-bl-full"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<motion.span
|
||||
className="text-xs font-bold uppercase tracking-wider text-[hsl(var(--teal))]"
|
||||
animate={{ opacity: [1, 0.7, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
✓ SIGNAL DETECTED
|
||||
</motion.span>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[hsl(var(--teal))]">
|
||||
<Filter className="h-3 w-3" />
|
||||
Filtered
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-muted-foreground mb-3">Enterprise Plan</p>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<p className="text-3xl font-bold text-foreground">$99/mo</p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -10, scale: 0.9 }}
|
||||
animate={{
|
||||
opacity: phase >= 2 ? 1 : 0,
|
||||
x: phase >= 2 ? 0 : -10,
|
||||
scale: phase >= 2 ? 1 : 0.9
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="text-lg text-[hsl(var(--burgundy))] font-bold flex items-center gap-1"
|
||||
>
|
||||
<span>→</span>
|
||||
<motion.span
|
||||
animate={{ scale: phase === 2 ? [1, 1.1, 1] : 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
$79/mo
|
||||
</motion.span>
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Alert badge */}
|
||||
{phase >= 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
<span className="text-[10px] font-bold text-[hsl(var(--burgundy))] uppercase tracking-wider">
|
||||
Alert Sent
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Phase Indicator */}
|
||||
<div className="absolute bottom-4 right-4 flex gap-1.5">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{
|
||||
width: phase === i ? 24 : 6,
|
||||
backgroundColor: phase === i ? 'hsl(var(--teal))' : 'hsl(var(--border))'
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-1.5 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. USE CASE SHOWCASE - SEO, Competitor, Policy
|
||||
// ============================================
|
||||
export function UseCaseShowcase() {
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Search className="h-7 w-7" />,
|
||||
title: 'SEO Monitoring',
|
||||
problem: 'Your rankings drop before you know why.',
|
||||
example: 'Track when competitors update meta descriptions or add new content sections that outrank you.',
|
||||
color: 'teal',
|
||||
gradient: 'from-[hsl(var(--teal))]/10 to-transparent',
|
||||
demoComponent: <SEODemoVisual />
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="h-7 w-7" />,
|
||||
title: 'Competitor Intelligence',
|
||||
problem: 'Competitor launches slip past your radar.',
|
||||
example: 'Monitor pricing pages, product launches, and promotional campaigns in real-time.',
|
||||
color: 'primary',
|
||||
gradient: 'from-[hsl(var(--primary))]/10 to-transparent',
|
||||
demoComponent: <CompetitorDemoVisual />
|
||||
},
|
||||
{
|
||||
icon: <FileCheck className="h-7 w-7" />,
|
||||
title: 'Policy & Compliance',
|
||||
problem: 'Regulatory updates appear without warning.',
|
||||
example: 'Track policy changes, terms updates, and legal text modifications with audit-proof history.',
|
||||
color: 'burgundy',
|
||||
gradient: 'from-[hsl(var(--burgundy))]/10 to-transparent',
|
||||
demoComponent: <PolicyDemoVisual />
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-[hsl(var(--section-bg-3))] relative overflow-hidden">
|
||||
{/* Background Decor - Enhanced Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border))_1px,transparent_1px)] bg-[size:4rem_4rem] opacity-30 [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.div variants={fadeInUp} className="inline-flex items-center gap-2 rounded-full bg-secondary border border-border px-4 py-1.5 text-sm font-medium text-foreground mb-6">
|
||||
<Eye className="h-4 w-4" />
|
||||
Who This Is For
|
||||
</motion.div>
|
||||
<motion.h2 variants={fadeInUp} custom={1} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
Built for teams who need results,{' '}
|
||||
<span className="text-muted-foreground">not demos.</span>
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Use Case Cards - Diagonal Cascade */}
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{useCases.map((useCase, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 40, rotateX: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.15, duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
whileHover={{ y: -12, scale: 1.02, transition: { duration: 0.3 } }}
|
||||
className="group relative glass-card rounded-3xl shadow-xl hover:shadow-2xl transition-all overflow-hidden"
|
||||
>
|
||||
{/* Gradient Background */}
|
||||
<div className={`absolute inset-0 rounded-3xl bg-gradient-to-br ${useCase.gradient} opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
|
||||
<div className="relative z-10 p-8 space-y-6">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[hsl(var(--${useCase.color}))]/10 text-[hsl(var(--${useCase.color}))] border border-[hsl(var(--${useCase.color}))]/20`}
|
||||
>
|
||||
{useCase.icon}
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-display font-bold text-foreground">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
|
||||
{/* Problem Statement */}
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{useCase.problem}
|
||||
</p>
|
||||
|
||||
{/* Animated Demo Visual */}
|
||||
<div className="!mt-6 rounded-xl overflow-hidden border border-border/50 shadow-inner">
|
||||
{useCase.demoComponent}
|
||||
</div>
|
||||
|
||||
{/* Example Scenario */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-xs uppercase tracking-wider font-bold text-muted-foreground mb-2">
|
||||
Example:
|
||||
</p>
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
{useCase.example}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. HOW IT WORKS - 4 Stage Flow
|
||||
// ============================================
|
||||
export function HowItWorks() {
|
||||
const stages = [
|
||||
{ icon: <Target className="h-6 w-6" />, title: 'Set URL', desc: 'Add the page you want to monitor' },
|
||||
{ icon: <Zap className="h-6 w-6" />, title: 'Check regularly', desc: 'Automated checks at your chosen frequency' },
|
||||
{ icon: <Filter className="h-6 w-6" />, title: 'Remove noise', desc: 'AI filters out irrelevant changes' },
|
||||
{ icon: <Bell className="h-6 w-6" />, title: 'Get alerted', desc: 'Receive notifications that matter' }
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-4))] to-[hsl(var(--section-bg-5))] relative overflow-hidden">
|
||||
{/* Subtle Diagonal Stripe Decoration */}
|
||||
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, hsl(var(--primary)), hsl(var(--primary)) 2px, transparent 2px, transparent 40px)' }} />
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
How it works
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Four simple steps to never miss an important change again.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Horizontal Flow */}
|
||||
<div className="relative">
|
||||
{/* Connecting Line */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-border to-transparent -translate-y-1/2 hidden lg:block" />
|
||||
|
||||
<div className="grid lg:grid-cols-4 gap-8 lg:gap-4">
|
||||
{stages.map((stage, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="relative flex flex-col items-center text-center group"
|
||||
>
|
||||
{/* Large Number Background */}
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 text-8xl font-display font-bold text-border/20 pointer-events-none">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
{/* Circle Container */}
|
||||
<div className="relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-full border-2 border-border bg-card shadow-lg group-hover:shadow-2xl group-hover:border-[hsl(var(--primary))] group-hover:bg-[hsl(var(--primary))]/5 transition-all">
|
||||
<div className="text-[hsl(var(--primary))]">
|
||||
{stage.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h3 className="text-lg font-bold text-foreground mb-2">
|
||||
{stage.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[200px]">
|
||||
{stage.desc}
|
||||
</p>
|
||||
|
||||
{/* Arrow (not on last) */}
|
||||
{i < stages.length - 1 && (
|
||||
<div className="hidden lg:block absolute top-10 -right-4 text-border">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. DIFFERENTIATORS - Why We're Better
|
||||
// ============================================
|
||||
export function Differentiators() {
|
||||
const features = [
|
||||
{ feature: 'Noise Filtering', others: 'Basic', us: 'AI-powered + custom rules', icon: <Filter className="h-5 w-5" /> },
|
||||
{ feature: 'Keyword Alerts', others: 'Limited', us: 'Regex + thresholds', icon: <Search className="h-5 w-5" /> },
|
||||
{ feature: 'Integrations', others: 'Email only', us: 'Slack, Webhooks, Teams', icon: <Slack className="h-5 w-5" /> },
|
||||
{ feature: 'History & Proof', others: '7-30 days', us: 'Unlimited snapshots', icon: <History className="h-5 w-5" /> },
|
||||
{ feature: 'Setup Time', others: '15+ min', us: '2 minutes', icon: <Zap className="h-5 w-5" /> },
|
||||
{ feature: 'Pricing', others: '$29-99/mo', us: 'Fair pay-per-use', icon: <Shield className="h-5 w-5" /> }
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-[hsl(var(--section-bg-5))] relative overflow-hidden">
|
||||
{/* Radial Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,hsl(var(--teal))_0%,transparent_50%)] opacity-5" />
|
||||
<div className="mx-auto max-w-6xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
Why we're{' '}
|
||||
<span className="text-[hsl(var(--teal))]">different</span>
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Not all monitoring tools are created equal. Here's what sets us apart.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Feature Cards Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05, duration: 0.4 }}
|
||||
className="group relative glass-card rounded-2xl p-6 hover:border-[hsl(var(--teal))]/30 hover:shadow-xl transition-all hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))] mb-4 group-hover:scale-110 transition-transform">
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Feature Name */}
|
||||
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||
{item.feature}
|
||||
</h3>
|
||||
|
||||
{/* Comparison */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs uppercase tracking-wider font-bold text-muted-foreground flex-shrink-0 mt-0.5">Others:</span>
|
||||
<span className="text-sm text-muted-foreground">{item.others}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-[hsl(var(--teal))]/5 border border-[hsl(var(--teal))]/20">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))] flex-shrink-0 mt-0.5" strokeWidth={3} />
|
||||
<span className="text-sm font-semibold text-foreground">{item.us}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. SOCIAL PROOF - Testimonials (Prepared for Beta)
|
||||
// ============================================
|
||||
export function SocialProof() {
|
||||
const testimonials = [
|
||||
{
|
||||
quote: "The noise filtering alone saves me 2 hours per week. Finally, monitoring that actually works.",
|
||||
author: "[Beta User]",
|
||||
role: "SEO Manager",
|
||||
company: "[Company]",
|
||||
useCase: "SEO Monitoring"
|
||||
},
|
||||
{
|
||||
quote: "We catch competitor price changes within minutes. Game-changer for our pricing strategy.",
|
||||
author: "[Beta User]",
|
||||
role: "Growth Lead",
|
||||
company: "[Company]",
|
||||
useCase: "Competitor Intelligence"
|
||||
},
|
||||
{
|
||||
quote: "Audit-proof history saved us during compliance review. Worth every penny.",
|
||||
author: "[Beta User]",
|
||||
role: "Compliance Officer",
|
||||
company: "[Company]",
|
||||
useCase: "Policy Tracking"
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-foreground to-[hsl(var(--foreground))]/95 relative overflow-hidden text-white">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
backgroundSize: '60px 60px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold mb-6">
|
||||
Built for teams who need results,{' '}
|
||||
<span className="text-[hsl(var(--primary))]">not demos.</span>
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Testimonial Cards - Minimal & Uniform */}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{testimonials.map((testimonial, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -4 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
{/* Subtle gradient border glow */}
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-[hsl(var(--primary))] to-[hsl(var(--teal))] rounded-3xl blur opacity-15 group-hover:opacity-25 transition-opacity duration-300" />
|
||||
|
||||
{/* Main Card - fixed height for uniformity */}
|
||||
<div className="relative h-full flex flex-col rounded-3xl bg-white/10 backdrop-blur-sm border border-white/20 p-8 group-hover:bg-white/12 transition-colors duration-300">
|
||||
{/* Large Quote Mark */}
|
||||
<div className="text-5xl font-display text-[hsl(var(--primary))]/30 leading-none mb-3">
|
||||
"
|
||||
</div>
|
||||
|
||||
{/* Quote - flex-grow ensures cards align */}
|
||||
<p className="font-body text-base leading-relaxed mb-6 text-white/90 font-medium italic flex-grow">
|
||||
{testimonial.quote}
|
||||
</p>
|
||||
|
||||
{/* Attribution - always at bottom */}
|
||||
<div className="flex items-start justify-between mt-auto">
|
||||
<div>
|
||||
<p className="font-bold text-white text-sm">{testimonial.author}</p>
|
||||
<p className="text-xs text-white/70">{testimonial.role} at {testimonial.company}</p>
|
||||
</div>
|
||||
<div className="px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 border border-[hsl(var(--teal))]/30 text-[10px] font-bold uppercase tracking-wider text-[hsl(var(--teal))]">
|
||||
{testimonial.useCase}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-12 text-sm text-white/60"
|
||||
>
|
||||
Join our waitlist to become a beta tester and get featured here.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6. FINAL CTA - Get Started
|
||||
// ============================================
|
||||
export function FinalCTA({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-32">
|
||||
{/* Animated Gradient Mesh Background - More dramatic */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[hsl(var(--primary))]/30 via-[hsl(var(--burgundy))]/20 to-[hsl(var(--teal))]/30 opacity-70" />
|
||||
<div className="absolute inset-0 grain-texture" />
|
||||
|
||||
{/* Animated Orbs - Enhanced */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [0.4, 0.6, 0.4],
|
||||
rotate: [0, 180, 360]
|
||||
}}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-1/4 -left-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary))] blur-[140px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.4, 0.5, 0.4],
|
||||
rotate: [360, 180, 0]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "linear", delay: 2 }}
|
||||
className="absolute bottom-1/4 -right-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--teal))] blur-[140px]"
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 text-center relative z-10">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Headline */}
|
||||
<motion.h2 variants={fadeInUp} className="text-5xl lg:text-6xl font-display font-bold leading-tight text-foreground">
|
||||
Stop missing the changes{' '}
|
||||
<span className="text-[hsl(var(--primary))]">that matter.</span>
|
||||
</motion.h2>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl lg:text-2xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Join the waitlist and be first to experience monitoring that actually works.
|
||||
</motion.p>
|
||||
|
||||
{/* Waitlist Form - replaces button */}
|
||||
<motion.div variants={fadeInUp} custom={2} className="pt-4">
|
||||
{isAuthenticated ? (
|
||||
<MagneticButton strength={0.15}>
|
||||
<Link href="/dashboard">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-16 rounded-full bg-[hsl(var(--burgundy))] px-12 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 font-bold text-lg group"
|
||||
>
|
||||
Go to Dashboard
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
) : (
|
||||
<WaitlistForm />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Social Proof Indicator */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={3}
|
||||
className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-2 h-2 rounded-full bg-green-500"
|
||||
/>
|
||||
<span className="font-semibold text-foreground">500+ joined this week</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
|
||||
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
179
frontend/components/landing/LiveStatsBar.tsx
Normal file
179
frontend/components/landing/LiveStatsBar.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Activity, TrendingUp, Zap, Shield } from 'lucide-react'
|
||||
|
||||
function AnimatedNumber({ value, suffix = '' }: { value: number; suffix?: string }) {
|
||||
const [displayValue, setDisplayValue] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 2000 // 2 seconds
|
||||
const steps = 60
|
||||
const increment = value / steps
|
||||
const stepDuration = duration / steps
|
||||
|
||||
let currentStep = 0
|
||||
const interval = setInterval(() => {
|
||||
currentStep++
|
||||
if (currentStep <= steps) {
|
||||
setDisplayValue(Math.floor(increment * currentStep))
|
||||
} else {
|
||||
setDisplayValue(value)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, stepDuration)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
|
||||
{displayValue.toLocaleString()}{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FluctuatingNumber({ base, variance }: { base: number; variance: number }) {
|
||||
const [value, setValue] = useState(base)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const fluctuation = (Math.random() - 0.5) * variance
|
||||
setValue(base + fluctuation)
|
||||
}, 1500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [base, variance])
|
||||
|
||||
return (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
|
||||
{Math.round(value)}ms
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LiveStatsBar() {
|
||||
const stats = [
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
label: 'Checks performed today',
|
||||
value: 2847,
|
||||
type: 'counter' as const
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
label: 'Changes detected this hour',
|
||||
value: 127,
|
||||
type: 'counter' as const
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
label: 'Uptime',
|
||||
value: '99.9%',
|
||||
type: 'static' as const
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
label: 'Avg response time',
|
||||
value: '< ',
|
||||
type: 'fluctuating' as const,
|
||||
base: 42,
|
||||
variance: 10
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="border-y border-border bg-gradient-to-r from-foreground/95 via-foreground to-foreground/95 py-8 overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{/* Desktop: Grid */}
|
||||
<div className="hidden lg:grid lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="flex flex-col items-center text-center gap-3"
|
||||
>
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="flex items-center justify-center w-12 h-12 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{stat.icon}
|
||||
</motion.div>
|
||||
|
||||
{/* Value */}
|
||||
<div>
|
||||
{stat.type === 'counter' && typeof stat.value === 'number' && (
|
||||
<AnimatedNumber value={stat.value} />
|
||||
)}
|
||||
{stat.type === 'static' && (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}
|
||||
</span>
|
||||
)}
|
||||
{stat.type === 'fluctuating' && stat.base && stat.variance && (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className="text-xs font-medium text-white/90 uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Horizontal Scroll */}
|
||||
<div className="lg:hidden overflow-x-auto scrollbar-thin pb-2">
|
||||
<div className="flex gap-8 min-w-max px-4">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="flex flex-col items-center text-center gap-3 min-w-[160px]"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]">
|
||||
{stat.icon}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div>
|
||||
{stat.type === 'counter' && typeof stat.value === 'number' && (
|
||||
<AnimatedNumber value={stat.value} />
|
||||
)}
|
||||
{stat.type === 'static' && (
|
||||
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}
|
||||
</span>
|
||||
)}
|
||||
{stat.type === 'fluctuating' && stat.base && stat.variance && (
|
||||
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className="text-[10px] font-medium text-white/90 uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
120
frontend/components/landing/MagneticElements.tsx
Normal file
120
frontend/components/landing/MagneticElements.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
|
||||
import { useRef, ReactNode } from 'react'
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
strength?: number
|
||||
}
|
||||
|
||||
export function MagneticButton({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
strength = 0.3
|
||||
}: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const x = useMotionValue(0)
|
||||
const y = useMotionValue(0)
|
||||
|
||||
const springConfig = { stiffness: 300, damping: 20 }
|
||||
const springX = useSpring(x, springConfig)
|
||||
const springY = useSpring(y, springConfig)
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return
|
||||
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const deltaX = (e.clientX - centerX) * strength
|
||||
const deltaY = (e.clientY - centerY) * strength
|
||||
|
||||
x.set(deltaX)
|
||||
y.set(deltaY)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0)
|
||||
y.set(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
style={{ x: springX, y: springY }}
|
||||
className={`inline-block ${className}`}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionDividerProps {
|
||||
variant?: 'wave' | 'diagonal' | 'curve'
|
||||
fromColor?: string
|
||||
toColor?: string
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
export function SectionDivider({
|
||||
variant = 'wave',
|
||||
fromColor = 'section-bg-3',
|
||||
toColor = 'section-bg-4',
|
||||
flip = false
|
||||
}: SectionDividerProps) {
|
||||
if (variant === 'wave') {
|
||||
return (
|
||||
<div className={`w-full h-20 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M0,0 Q300,80 600,40 T1200,0 L1200,120 L0,120 Z"
|
||||
fill={`hsl(var(--${toColor}))`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'diagonal') {
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-16 ${flip ? '-skew-y-2' : 'skew-y-2'}`}
|
||||
style={{
|
||||
background: `linear-gradient(to bottom right, hsl(var(--${fromColor})), hsl(var(--${toColor})))`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'curve') {
|
||||
return (
|
||||
<div className={`w-full h-24 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M0,60 Q300,120 600,60 T1200,60 L1200,120 L0,120 Z"
|
||||
fill={`hsl(var(--${toColor}))`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
148
frontend/components/landing/PolicyDemoVisual.tsx
Normal file
148
frontend/components/landing/PolicyDemoVisual.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FileCheck, Check } from 'lucide-react'
|
||||
|
||||
export function PolicyDemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--burgundy))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* Document Header */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="h-4 w-4 text-[hsl(var(--burgundy))]" />
|
||||
<span className="text-xs font-bold text-foreground">Terms of Service</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="px-2 py-0.5 rounded-full border text-[9px] font-bold"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.1)' : 'transparent',
|
||||
color: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--muted-foreground))'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{phase === 0 ? 'v2.1' : 'v2.2'}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Document Content */}
|
||||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg border-2 bg-white overflow-hidden"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Section 4.2 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-[hsl(var(--primary))]">
|
||||
Section 4.2 - Data Retention
|
||||
</div>
|
||||
|
||||
{/* Text Lines */}
|
||||
<div className="space-y-1 text-[9px] text-muted-foreground leading-relaxed">
|
||||
<p>We will retain your personal data for</p>
|
||||
|
||||
{/* Changing text */}
|
||||
<motion.div
|
||||
className="relative rounded"
|
||||
layout
|
||||
>
|
||||
<motion.p
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.15)' : 'transparent',
|
||||
paddingLeft: phase === 1 ? '4px' : '0px',
|
||||
paddingRight: phase === 1 ? '4px' : '0px',
|
||||
color: phase === 1 ? 'hsl(var(--burgundy))' : 'hsl(var(--muted-foreground))',
|
||||
fontWeight: phase === 1 ? 600 : 400
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="relative inline-block rounded"
|
||||
>
|
||||
{phase === 0 ? (
|
||||
'as long as necessary to fulfill purposes'
|
||||
) : (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
a minimum of 90 days after account deletion
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.p>
|
||||
|
||||
{/* Change highlight indicator */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<p>outlined in our Privacy Policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Stats */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="pt-2 border-t border-border flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-[8px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded bg-green-500/20 border border-green-500" />
|
||||
+18 words
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
|
||||
-7 words
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Audit Trail Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--teal))] text-white">
|
||||
<Check className="h-3 w-3" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[9px] font-bold text-[hsl(var(--teal))]">
|
||||
Audit trail saved
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground">
|
||||
Snapshot archived for compliance
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
255
frontend/components/landing/PricingComparison.tsx
Normal file
255
frontend/components/landing/PricingComparison.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { TrendingDown, DollarSign } from 'lucide-react'
|
||||
|
||||
export function PricingComparison() {
|
||||
const [monitorCount, setMonitorCount] = useState(50)
|
||||
|
||||
// Pricing calculation logic
|
||||
const calculatePricing = (monitors: number) => {
|
||||
// Competitors: tiered pricing
|
||||
let competitorMin, competitorMax
|
||||
if (monitors <= 10) {
|
||||
competitorMin = 29
|
||||
competitorMax = 49
|
||||
} else if (monitors <= 50) {
|
||||
competitorMin = 79
|
||||
competitorMax = 129
|
||||
} else if (monitors <= 100) {
|
||||
competitorMin = 129
|
||||
competitorMax = 199
|
||||
} else {
|
||||
competitorMin = 199
|
||||
competitorMax = 299
|
||||
}
|
||||
|
||||
// Our pricing: simpler, fairer
|
||||
let ourPrice
|
||||
if (monitors <= 10) {
|
||||
ourPrice = 19
|
||||
} else if (monitors <= 50) {
|
||||
ourPrice = 49
|
||||
} else if (monitors <= 100) {
|
||||
ourPrice = 89
|
||||
} else {
|
||||
ourPrice = 149
|
||||
}
|
||||
|
||||
const competitorAvg = (competitorMin + competitorMax) / 2
|
||||
const savings = competitorAvg - ourPrice
|
||||
const savingsPercent = Math.round((savings / competitorAvg) * 100)
|
||||
|
||||
return {
|
||||
competitorMin,
|
||||
competitorMax,
|
||||
competitorAvg,
|
||||
ourPrice,
|
||||
savings,
|
||||
savingsPercent
|
||||
}
|
||||
}
|
||||
|
||||
const pricing = calculatePricing(monitorCount)
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-6))] to-[hsl(var(--section-bg-3))] relative overflow-hidden">
|
||||
{/* Background Pattern - Enhanced Dot Grid */}
|
||||
<div className="absolute inset-0 opacity-8">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `radial-gradient(circle, hsl(var(--teal)) 1.5px, transparent 1.5px)`,
|
||||
backgroundSize: '30px 30px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))] mb-6">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Fair Pricing
|
||||
</div>
|
||||
<h2 className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
See how much you{' '}
|
||||
<span className="text-[hsl(var(--teal))]">save</span>
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Compare our transparent pricing with typical competitors. No hidden fees, no surprises.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Interactive Comparison Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="rounded-3xl border-2 border-border bg-card p-8 lg:p-12 shadow-2xl"
|
||||
>
|
||||
{/* Monitor Count Slider */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<label className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Number of Monitors
|
||||
</label>
|
||||
<motion.div
|
||||
key={monitorCount}
|
||||
initial={{ scale: 1.2 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-4xl font-bold text-foreground font-mono"
|
||||
>
|
||||
{monitorCount}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="200"
|
||||
step="5"
|
||||
value={monitorCount}
|
||||
onChange={(e) => setMonitorCount(Number(e.target.value))}
|
||||
className="w-full h-3 bg-secondary rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[hsl(var(--teal))]
|
||||
[&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:cursor-grab
|
||||
[&::-webkit-slider-thumb]:active:cursor-grabbing [&::-webkit-slider-thumb]:hover:scale-110
|
||||
[&::-webkit-slider-thumb]:transition-transform
|
||||
[&::-moz-range-thumb]:w-6 [&::-moz-range-thumb]:h-6
|
||||
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[hsl(var(--teal))]
|
||||
[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-lg
|
||||
[&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing"
|
||||
/>
|
||||
{/* Tick marks - positioned by percentage based on slider range (5-200) */}
|
||||
<div className="relative mt-2 h-4">
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: '0%', transform: 'translateX(0)' }}>5</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((50 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>50</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((100 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>100</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: '100%', transform: 'translateX(-100%)' }}>200</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Comparison Bars */}
|
||||
<div className="grid lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Competitors */}
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Typical Competitors
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<motion.div
|
||||
className="relative h-24 rounded-2xl bg-gradient-to-r from-red-500/10 to-red-500/20 border-2 border-red-500/30 overflow-hidden"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-red-500/20 to-red-500/40"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
key={`comp-${pricing.competitorMin}-${pricing.competitorMax}`}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-4xl font-bold text-red-700 font-mono"
|
||||
>
|
||||
${pricing.competitorMin}-{pricing.competitorMax}
|
||||
</motion.div>
|
||||
<div className="text-xs font-medium text-red-600">per month</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Us */}
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
|
||||
Our Pricing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<motion.div
|
||||
className="relative h-24 rounded-2xl bg-gradient-to-r from-[hsl(var(--teal))]/10 to-[hsl(var(--teal))]/20 border-2 border-[hsl(var(--teal))]/30 overflow-hidden"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-[hsl(var(--teal))]/20 to-[hsl(var(--teal))]/40"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: pricing.ourPrice / pricing.competitorMax }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
key={`our-${pricing.ourPrice}`}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-5xl font-bold text-[hsl(var(--teal))] font-mono"
|
||||
>
|
||||
${pricing.ourPrice}
|
||||
</motion.div>
|
||||
<div className="text-xs font-medium text-[hsl(var(--teal))]">per month</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Savings Badge */}
|
||||
<motion.div
|
||||
key={`savings-${pricing.savings}`}
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center justify-center gap-4 p-6 rounded-2xl bg-gradient-to-r from-[hsl(var(--primary))]/10 via-[hsl(var(--teal))]/10 to-[hsl(var(--burgundy))]/10 border-2 border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<TrendingDown className="h-8 w-8 text-[hsl(var(--teal))]" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-muted-foreground">You save</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
${Math.round(pricing.savings)}
|
||||
</span>
|
||||
<span className="text-xl text-muted-foreground">/month</span>
|
||||
<span className="ml-2 px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 text-sm font-bold text-[hsl(var(--teal))]">
|
||||
{pricing.savingsPercent}% off
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Fine Print */}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
* Based on average pricing from Visualping, Distill.io, and similar competitors as of Jan 2026
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
124
frontend/components/landing/SEODemoVisual.tsx
Normal file
124
frontend/components/landing/SEODemoVisual.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
export function SEODemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const oldMeta = "Best enterprise software for teams of all sizes. Try free for 30 days."
|
||||
const newMeta = "Best enterprise software for teams of all sizes. Try free for 30 days. Now with AI-powered analytics and real-time collaboration."
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--teal))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* SERP Result */}
|
||||
<div className="space-y-4">
|
||||
{/* Ranking Indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-mono text-muted-foreground">
|
||||
google.com/search
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-background border border-border"
|
||||
animate={{
|
||||
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--burgundy))',
|
||||
backgroundColor: phase === 0 ? 'hsl(var(--background))' : 'hsl(var(--burgundy) / 0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="text-xs font-bold">Ranking:</span>
|
||||
<motion.span
|
||||
key={phase}
|
||||
initial={{ y: -10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 10, opacity: 0 }}
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
#{phase === 0 ? '3' : '5'}
|
||||
</motion.span>
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
>
|
||||
<TrendingDown className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* SERP Snippet */}
|
||||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg bg-white border-2"
|
||||
animate={{
|
||||
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--teal))',
|
||||
boxShadow: phase === 0
|
||||
? '0 1px 3px rgba(0,0,0,0.1)'
|
||||
: '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-primary" />
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
competitor.com/product
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-sm font-bold text-[hsl(var(--primary))] line-clamp-1">
|
||||
Best Enterprise Software Solution 2026
|
||||
</h4>
|
||||
|
||||
{/* Meta Description with change highlighting */}
|
||||
<motion.p
|
||||
className="text-[11px] text-muted-foreground leading-relaxed relative"
|
||||
layout
|
||||
>
|
||||
<motion.span
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.2)' : 'transparent'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-block rounded px-0.5"
|
||||
>
|
||||
{phase === 0 ? oldMeta : newMeta}
|
||||
</motion.span>
|
||||
|
||||
{/* Change indicator */}
|
||||
{phase === 1 && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
|
||||
>
|
||||
NEW
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Alert Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
className="flex items-center justify-center gap-2 text-[10px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[hsl(var(--teal))] animate-pulse" />
|
||||
Meta Description Changed
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
frontend/components/landing/WaitlistForm.tsx
Normal file
256
frontend/components/landing/WaitlistForm.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Check, ArrowRight, Loader2, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function WaitlistForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [queuePosition, setQueuePosition] = useState(0)
|
||||
const [confetti, setConfetti] = useState<Array<{ id: number; x: number; y: number; rotation: number; color: string }>>([])
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
const triggerConfetti = () => {
|
||||
const colors = ['hsl(var(--primary))', 'hsl(var(--teal))', 'hsl(var(--burgundy))', '#fbbf24', '#f97316']
|
||||
const particles = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: Date.now() + i,
|
||||
x: 50 + (Math.random() - 0.5) * 40, // Center around 50%
|
||||
y: 50,
|
||||
rotation: Math.random() * 360,
|
||||
color: colors[Math.floor(Math.random() * colors.length)]
|
||||
}))
|
||||
setConfetti(particles)
|
||||
setTimeout(() => setConfetti([]), 3000)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!email) {
|
||||
setError('Please enter your email')
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError('Please enter a valid email')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Generate a random queue position
|
||||
const position = Math.floor(Math.random() * 500) + 400
|
||||
|
||||
setQueuePosition(position)
|
||||
setIsSubmitting(false)
|
||||
setIsSuccess(true)
|
||||
triggerConfetti()
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative max-w-md mx-auto"
|
||||
>
|
||||
{/* Confetti */}
|
||||
{confetti.map(particle => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: particle.color,
|
||||
left: `${particle.x}%`,
|
||||
top: `${particle.y}%`
|
||||
}}
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
animate={{
|
||||
y: [-20, window.innerHeight / 4],
|
||||
x: [(Math.random() - 0.5) * 200],
|
||||
opacity: [1, 1, 0],
|
||||
rotate: [particle.rotation, particle.rotation + 720],
|
||||
scale: [1, 0.5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2 + Math.random(),
|
||||
ease: [0.45, 0, 0.55, 1]
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Success Card */}
|
||||
<motion.div
|
||||
initial={{ y: 20 }}
|
||||
animate={{ y: 0 }}
|
||||
className="relative overflow-hidden rounded-3xl border-2 border-[hsl(var(--teal))] bg-white shadow-2xl shadow-[hsl(var(--teal))]/20 p-8 text-center"
|
||||
>
|
||||
{/* Animated background accent */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[hsl(var(--primary))] via-[hsl(var(--teal))] to-[hsl(var(--burgundy))]"
|
||||
animate={{
|
||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
|
||||
}}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
style={{ backgroundSize: '200% 100%' }}
|
||||
/>
|
||||
|
||||
{/* Success Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.2 }}
|
||||
className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[hsl(var(--teal))]/10 border-2 border-[hsl(var(--teal))]"
|
||||
>
|
||||
<Check className="h-10 w-10 text-[hsl(var(--teal))]" strokeWidth={3} />
|
||||
</motion.div>
|
||||
|
||||
{/* Success Message */}
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-3 text-3xl font-display font-bold text-foreground"
|
||||
>
|
||||
You're on the list!
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mb-6 text-muted-foreground"
|
||||
>
|
||||
Check your inbox for confirmation
|
||||
</motion.p>
|
||||
|
||||
{/* Queue Position */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="inline-flex items-center gap-3 rounded-full bg-gradient-to-r from-[hsl(var(--primary))]/10 to-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30 px-6 py-3"
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-[hsl(var(--primary))]" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Your position
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
#{queuePosition}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bonus Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
|
||||
🎉 Early access: 50% off for 6 months
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
className="flex-1 relative"
|
||||
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="Enter your email"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${
|
||||
error
|
||||
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
|
||||
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trust Signals Below Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-6 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<span>No credit card needed</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<span>No spam, ever</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.form>
|
||||
)
|
||||
}
|
||||
49
frontend/components/layout/dashboard-layout.tsx
Normal file
49
frontend/components/layout/dashboard-layout.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, title, description }: DashboardLayoutProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area - responsive margin for sidebar */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Header */}
|
||||
{(title || description) && (
|
||||
<header className="sticky top-0 z-30 border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="px-8 py-6 pl-16 lg:pl-8">
|
||||
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Page Content - extra top padding on mobile for hamburger button */}
|
||||
<main className="p-8 pt-4 lg:pt-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
250
frontend/components/layout/sidebar.tsx
Normal file
250
frontend/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { settingsAPI } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearAuth } from '@/lib/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Monitors',
|
||||
href: '/monitors',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Incidents',
|
||||
href: '/incidents',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Analytics',
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Fetch user settings to show current plan
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await settingsAPI.get()
|
||||
return response.settings || {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Default to stored user plan from localStorage if API fails or is loading
|
||||
const getStoredPlan = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) return JSON.parse(userStr).plan;
|
||||
} catch { return 'free'; }
|
||||
}
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// Capitalize plan name
|
||||
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
|
||||
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
|
||||
|
||||
// Determine badge color
|
||||
const getBadgeVariant = (plan: string) => {
|
||||
switch (plan?.toLowerCase()) {
|
||||
case 'pro': return 'default'; // Primary color
|
||||
case 'business': return 'secondary';
|
||||
case 'enterprise': return 'destructive'; // Or another prominent color
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const sidebarOpen = isOpen !== undefined ? isOpen : mobileOpen
|
||||
const handleClose = onClose || (() => setMobileOpen(false))
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') {
|
||||
return pathname === '/dashboard'
|
||||
}
|
||||
if (href === '/monitors') {
|
||||
return pathname === '/monitors' || pathname?.startsWith('/monitors/')
|
||||
}
|
||||
return pathname === href || pathname?.startsWith(href + '/')
|
||||
}
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile sidebar after navigation
|
||||
if (window.innerWidth < 1024) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border/50 shadow-md lg:hidden"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className="h-6 w-6 text-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={mobileOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-300"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 h-screen w-64 border-r border-border/50 bg-card/95 backdrop-blur-sm",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
"lg:translate-x-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-border/50 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-foreground">WebMonitor</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant={getBadgeVariant(planName)} className="px-1.5 py-0 h-5 text-[10px] uppercase">
|
||||
{planName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn(active && 'text-primary')}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
46
frontend/components/sparkline.tsx
Normal file
46
frontend/components/sparkline.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 40,
|
||||
color = 'currentColor',
|
||||
strokeWidth = 2
|
||||
}: SparklineProps) {
|
||||
if (!data || data.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Normalize data to fit height
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1 // Avoid division by zero
|
||||
|
||||
// Calculate points
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width
|
||||
const y = height - ((value - min) / range) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
40
frontend/components/ui/badge.tsx
Normal file
40
frontend/components/ui/badge.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary/10 text-primary",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive",
|
||||
success:
|
||||
"bg-green-100 text-green-700",
|
||||
warning:
|
||||
"bg-yellow-100 text-yellow-700",
|
||||
outline:
|
||||
"border border-border text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
85
frontend/components/ui/button.tsx
Normal file
85
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-md hover:bg-primary/90 hover:shadow-lg",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-secondary hover:text-secondary-foreground",
|
||||
link:
|
||||
"text-primary underline-offset-4 hover:underline",
|
||||
success:
|
||||
"bg-success text-success-foreground shadow-sm hover:bg-success/90",
|
||||
warning:
|
||||
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-12 rounded-lg px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
frontend/components/ui/card.tsx
Normal file
79
frontend/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { hover?: boolean }
|
||||
>(({ className, hover = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200",
|
||||
hover && "hover:shadow-md hover:border-primary/30 cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
47
frontend/components/ui/input.tsx
Normal file
47
frontend/components/ui/input.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, hint, id, ...props }, ref) => {
|
||||
const inputId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
54
frontend/components/ui/select.tsx
Normal file
54
frontend/components/ui/select.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
options: { value: string | number; label: string }[]
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, hint, id, options, ...props }, ref) => {
|
||||
const selectId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
"flex h-10 w-full appearance-none rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat pr-10",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = "Select"
|
||||
|
||||
export { Select }
|
||||
303
frontend/components/visual-selector.tsx
Normal file
303
frontend/components/visual-selector.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface VisualSelectorProps {
|
||||
url: string
|
||||
onSelect: (selector: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an optimal CSS selector for an element
|
||||
*/
|
||||
function generateSelector(element: Element): string {
|
||||
// Try ID first
|
||||
if (element.id) {
|
||||
return `#${element.id}`
|
||||
}
|
||||
|
||||
// Try unique class combination
|
||||
if (element.classList.length > 0) {
|
||||
const classes = Array.from(element.classList)
|
||||
const classSelector = '.' + classes.join('.')
|
||||
if (document.querySelectorAll(classSelector).length === 1) {
|
||||
return classSelector
|
||||
}
|
||||
}
|
||||
|
||||
// Build path from parent elements
|
||||
const path: string[] = []
|
||||
let current: Element | null = element
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let selector = current.tagName.toLowerCase()
|
||||
|
||||
if (current.id) {
|
||||
selector = `#${current.id}`
|
||||
path.unshift(selector)
|
||||
break
|
||||
}
|
||||
|
||||
if (current.classList.length > 0) {
|
||||
const significantClasses = Array.from(current.classList)
|
||||
.filter(c => !c.includes('hover') && !c.includes('active') && !c.includes('focus'))
|
||||
.slice(0, 2)
|
||||
if (significantClasses.length > 0) {
|
||||
selector += '.' + significantClasses.join('.')
|
||||
}
|
||||
}
|
||||
|
||||
// Add nth-child if needed for uniqueness
|
||||
const parent = current.parentElement
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter(
|
||||
c => c.tagName === current!.tagName
|
||||
)
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1
|
||||
selector += `:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector)
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return path.join(' > ')
|
||||
}
|
||||
|
||||
export function VisualSelector({ url, onSelect, onClose }: VisualSelectorProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedSelector, setSelectedSelector] = useState('')
|
||||
const [testResult, setTestResult] = useState<{ count: number; success: boolean } | null>(null)
|
||||
const [proxyHtml, setProxyHtml] = useState<string | null>(null)
|
||||
|
||||
// Fetch page content through proxy
|
||||
useEffect(() => {
|
||||
async function fetchProxyContent() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load page')
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
setProxyHtml(html)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load page')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProxyContent()
|
||||
}, [url])
|
||||
|
||||
// Handle clicks within the iframe
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentDocument) return
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
|
||||
// Inject selection styles
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `
|
||||
.visual-selector-hover {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
outline-offset: 2px;
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
.visual-selector-selected {
|
||||
outline: 3px solid #22c55e !important;
|
||||
outline-offset: 2px;
|
||||
background-color: rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
`
|
||||
doc.head.appendChild(style)
|
||||
|
||||
// Add event listeners
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target && target !== doc.body) {
|
||||
target.classList.add('visual-selector-hover')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseOut = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target) {
|
||||
target.classList.remove('visual-selector-hover')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (!target || target === doc.body) return
|
||||
|
||||
// Remove previous selection
|
||||
doc.querySelectorAll('.visual-selector-selected').forEach(el => {
|
||||
el.classList.remove('visual-selector-selected')
|
||||
})
|
||||
|
||||
// Add selection to current element
|
||||
target.classList.add('visual-selector-selected')
|
||||
|
||||
// Generate and set selector
|
||||
const selector = generateSelector(target)
|
||||
setSelectedSelector(selector)
|
||||
|
||||
// Test the selector
|
||||
const matches = doc.querySelectorAll(selector)
|
||||
setTestResult({
|
||||
count: matches.length,
|
||||
success: matches.length === 1
|
||||
})
|
||||
}
|
||||
|
||||
doc.body.addEventListener('mouseover', handleMouseOver)
|
||||
doc.body.addEventListener('mouseout', handleMouseOut)
|
||||
doc.body.addEventListener('click', handleClick)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
doc.body.removeEventListener('mouseover', handleMouseOver)
|
||||
doc.body.removeEventListener('mouseout', handleMouseOut)
|
||||
doc.body.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSelector) {
|
||||
onSelect(selectedSelector)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestSelector = () => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentDocument || !selectedSelector) return
|
||||
|
||||
try {
|
||||
const matches = iframe.contentDocument.querySelectorAll(selectedSelector)
|
||||
setTestResult({
|
||||
count: matches.length,
|
||||
success: matches.length === 1
|
||||
})
|
||||
|
||||
// Highlight matches
|
||||
iframe.contentDocument.querySelectorAll('.visual-selector-selected').forEach(el => {
|
||||
el.classList.remove('visual-selector-selected')
|
||||
})
|
||||
matches.forEach(el => {
|
||||
el.classList.add('visual-selector-selected')
|
||||
})
|
||||
} catch {
|
||||
setTestResult({ count: 0, success: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Visual Element Selector</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click on an element to select it. The CSS selector will be generated automatically.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
{/* URL display */}
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
Loading: {url}
|
||||
</div>
|
||||
|
||||
{/* Iframe container */}
|
||||
<div className="flex-1 relative border rounded-lg overflow-hidden bg-white">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Loading page...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-destructive font-medium">Failed to load page</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Note: Some sites may block embedding due to security policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyHtml && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={proxyHtml}
|
||||
className="w-full h-full"
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ minHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selector controls */}
|
||||
<div className="flex-shrink-0 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={selectedSelector}
|
||||
onChange={(e) => setSelectedSelector(e.target.value)}
|
||||
placeholder="CSS selector will appear here..."
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleTestSelector} disabled={!selectedSelector}>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`text-sm ${testResult.success ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
{testResult.success
|
||||
? `✓ Selector matches exactly 1 element`
|
||||
: `⚠ Selector matches ${testResult.count} elements (should be 1)`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedSelector}>
|
||||
Use This Selector
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +1,173 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/register', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Monitor API
|
||||
export const monitorAPI = {
|
||||
list: async () => {
|
||||
const response = await api.get('/monitors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const response = await api.get(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: any) => {
|
||||
const response = await api.post('/monitors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: any) => {
|
||||
const response = await api.put(`/monitors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
history: async (id: string, limit = 50) => {
|
||||
const response = await api.get(`/monitors/${id}/history`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/register', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
forgotPassword: async (email: string) => {
|
||||
const response = await api.post('/auth/forgot-password', { email });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resetPassword: async (token: string, newPassword: string) => {
|
||||
const response = await api.post('/auth/reset-password', { token, newPassword });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
verifyEmail: async (token: string) => {
|
||||
const response = await api.post('/auth/verify-email', { token });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resendVerification: async (email: string) => {
|
||||
const response = await api.post('/auth/resend-verification', { email });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Monitor API
|
||||
export const monitorAPI = {
|
||||
list: async () => {
|
||||
const response = await api.get('/monitors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const response = await api.get(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: any) => {
|
||||
const response = await api.post('/monitors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: any) => {
|
||||
const response = await api.put(`/monitors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
history: async (id: string, limit = 50) => {
|
||||
const response = await api.get(`/monitors/${id}/history`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportAuditTrail: async (id: string, format: 'json' | 'csv' = 'json') => {
|
||||
const token = localStorage.getItem('token');
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
const url = `${API_URL}/api/monitors/${id}/export?format=${format}`;
|
||||
|
||||
// Create a hidden link and trigger download
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename = response.headers.get('Content-Disposition')?.split('filename="')[1]?.replace('"', '')
|
||||
|| `export.${format}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
},
|
||||
};
|
||||
|
||||
// Settings API
|
||||
export const settingsAPI = {
|
||||
get: async () => {
|
||||
const response = await api.get('/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||
const response = await api.post('/settings/change-password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateNotifications: async (data: {
|
||||
emailEnabled?: boolean;
|
||||
webhookUrl?: string | null;
|
||||
webhookEnabled?: boolean;
|
||||
slackWebhookUrl?: string | null;
|
||||
slackEnabled?: boolean;
|
||||
}) => {
|
||||
const response = await api.put('/settings/notifications', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteAccount: async (password: string) => {
|
||||
const response = await api.delete('/settings/account', {
|
||||
data: { password },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
export function saveAuth(token: string, user: any) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userStr) return null;
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
return { token, user };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return !!getAuth();
|
||||
}
|
||||
export function saveAuth(token: string, user: any) {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const userStr = localStorage.getItem('user');
|
||||
|
||||
if (!token || !userStr) return null;
|
||||
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
return { token, user };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return !!getAuth();
|
||||
}
|
||||
|
||||
203
frontend/lib/templates.ts
Normal file
203
frontend/lib/templates.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
// Monitor Templates - Pre-configured monitoring setups for popular sites
|
||||
|
||||
export interface MonitorTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'ecommerce' | 'social' | 'news' | 'dev' | 'business' | 'other';
|
||||
icon: string;
|
||||
urlPattern: string; // Regex pattern for URL matching
|
||||
urlPlaceholder: string; // Example URL with placeholder
|
||||
selector?: string;
|
||||
ignoreRules: Array<{
|
||||
type: 'css' | 'regex' | 'text';
|
||||
value: string;
|
||||
description?: string;
|
||||
}>;
|
||||
keywordRules?: Array<{
|
||||
keyword: string;
|
||||
type: 'appears' | 'disappears' | 'count_increases' | 'count_decreases';
|
||||
}>;
|
||||
frequency: number; // in minutes
|
||||
}
|
||||
|
||||
export const monitorTemplates: MonitorTemplate[] = [
|
||||
// E-Commerce
|
||||
{
|
||||
id: 'amazon-price',
|
||||
name: 'Amazon Product Price',
|
||||
description: 'Track price changes on Amazon product pages',
|
||||
category: 'ecommerce',
|
||||
icon: '🛒',
|
||||
urlPattern: 'amazon\\.(com|de|co\\.uk|fr|es|it)/.*dp/[A-Z0-9]+',
|
||||
urlPlaceholder: 'https://amazon.com/dp/PRODUCTID',
|
||||
selector: '#priceblock_ourprice, .a-price .a-offscreen, #corePrice_feature_div .a-price-whole',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: '#nav, #rhf, #navFooter', description: 'Navigation and footer' },
|
||||
{ type: 'css', value: '.a-carousel, #recommendations', description: 'Recommendations' },
|
||||
{ type: 'regex', value: '\\d+ customer reviews?', description: 'Review count changes' }
|
||||
],
|
||||
frequency: 60
|
||||
},
|
||||
{
|
||||
id: 'ebay-price',
|
||||
name: 'eBay Listing Price',
|
||||
description: 'Monitor eBay listing prices and availability',
|
||||
category: 'ecommerce',
|
||||
icon: '🏷️',
|
||||
urlPattern: 'ebay\\.(com|de|co\\.uk)/itm/',
|
||||
urlPlaceholder: 'https://www.ebay.com/itm/ITEMID',
|
||||
selector: '.x-price-primary',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: '#vi-VR, #STORE_INFORMATION', description: 'Store info' }
|
||||
],
|
||||
frequency: 30
|
||||
},
|
||||
|
||||
// Developer Tools
|
||||
{
|
||||
id: 'github-releases',
|
||||
name: 'GitHub Releases',
|
||||
description: 'Get notified when new releases are published',
|
||||
category: 'dev',
|
||||
icon: '📦',
|
||||
urlPattern: 'github\\.com/[\\w-]+/[\\w-]+/releases',
|
||||
urlPlaceholder: 'https://github.com/owner/repo/releases',
|
||||
selector: '.release, [data-hpc] .Box-row',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: 'footer, .js-stale-session-flash', description: 'Footer' }
|
||||
],
|
||||
keywordRules: [
|
||||
{ keyword: 'Latest', type: 'appears' }
|
||||
],
|
||||
frequency: 360 // 6 hours
|
||||
},
|
||||
{
|
||||
id: 'npm-package',
|
||||
name: 'NPM Package',
|
||||
description: 'Track new versions of NPM packages',
|
||||
category: 'dev',
|
||||
icon: '📦',
|
||||
urlPattern: 'npmjs\\.com/package/[\\w@/-]+',
|
||||
urlPlaceholder: 'https://www.npmjs.com/package/package-name',
|
||||
selector: '#top h3, .css-1t74l4c',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: 'footer, .downloads', description: 'Footer and download stats' }
|
||||
],
|
||||
frequency: 1440 // Daily
|
||||
},
|
||||
|
||||
// News & Content
|
||||
{
|
||||
id: 'reddit-thread',
|
||||
name: 'Reddit Thread',
|
||||
description: 'Monitor a Reddit thread for new comments',
|
||||
category: 'social',
|
||||
icon: '📰',
|
||||
urlPattern: 'reddit\\.com/r/\\w+/comments/',
|
||||
urlPlaceholder: 'https://www.reddit.com/r/subreddit/comments/...',
|
||||
ignoreRules: [
|
||||
{ type: 'regex', value: '\\d+ points?', description: 'Vote counts' },
|
||||
{ type: 'regex', value: '\\d+ (minute|hour|day)s? ago', description: 'Timestamps' }
|
||||
],
|
||||
frequency: 30
|
||||
},
|
||||
{
|
||||
id: 'hackernews-front',
|
||||
name: 'Hacker News Front Page',
|
||||
description: 'Track top stories on Hacker News',
|
||||
category: 'news',
|
||||
icon: '📰',
|
||||
urlPattern: 'news\\.ycombinator\\.com/?$',
|
||||
urlPlaceholder: 'https://news.ycombinator.com/',
|
||||
selector: '.titleline',
|
||||
ignoreRules: [
|
||||
{ type: 'regex', value: '\\d+ points?', description: 'Points' },
|
||||
{ type: 'regex', value: '\\d+ comments?', description: 'Comment count' }
|
||||
],
|
||||
frequency: 60
|
||||
},
|
||||
|
||||
// Business & Jobs
|
||||
{
|
||||
id: 'job-board',
|
||||
name: 'Job Board',
|
||||
description: 'Monitor job postings on a company career page',
|
||||
category: 'business',
|
||||
icon: '💼',
|
||||
urlPattern: '.*/(careers?|jobs?)/?$',
|
||||
urlPlaceholder: 'https://company.com/careers',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: 'footer, nav, header', description: 'Navigation' }
|
||||
],
|
||||
keywordRules: [
|
||||
{ keyword: 'Senior', type: 'appears' },
|
||||
{ keyword: 'Remote', type: 'appears' }
|
||||
],
|
||||
frequency: 360 // 6 hours
|
||||
},
|
||||
{
|
||||
id: 'competitor-pricing',
|
||||
name: 'Competitor Pricing',
|
||||
description: 'Track competitor pricing page changes',
|
||||
category: 'business',
|
||||
icon: '💰',
|
||||
urlPattern: '.*/pricing/?$',
|
||||
urlPlaceholder: 'https://competitor.com/pricing',
|
||||
selector: '.price, .pricing-card, [class*="price"]',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: 'footer, nav', description: 'Navigation' }
|
||||
],
|
||||
frequency: 1440 // Daily
|
||||
},
|
||||
|
||||
// Generic
|
||||
{
|
||||
id: 'generic-page',
|
||||
name: 'Generic Web Page',
|
||||
description: 'Monitor any web page for changes',
|
||||
category: 'other',
|
||||
icon: '🌐',
|
||||
urlPattern: '.*',
|
||||
urlPlaceholder: 'https://example.com/page',
|
||||
ignoreRules: [
|
||||
{ type: 'css', value: 'script, style, noscript', description: 'Scripts' }
|
||||
],
|
||||
frequency: 60
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Find matching templates for a given URL
|
||||
*/
|
||||
export function findMatchingTemplates(url: string): MonitorTemplate[] {
|
||||
return monitorTemplates.filter(template => {
|
||||
try {
|
||||
const regex = new RegExp(template.urlPattern, 'i');
|
||||
return regex.test(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category
|
||||
*/
|
||||
export function getTemplatesByCategory(category: MonitorTemplate['category']): MonitorTemplate[] {
|
||||
return monitorTemplates.filter(t => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a template to create monitor configuration
|
||||
*/
|
||||
export function applyTemplate(template: MonitorTemplate, url: string) {
|
||||
return {
|
||||
url,
|
||||
name: `${template.name} Monitor`,
|
||||
frequency: template.frequency,
|
||||
elementSelector: template.selector || null,
|
||||
ignoreRules: template.ignoreRules,
|
||||
keywordRules: template.keywordRules || [],
|
||||
};
|
||||
}
|
||||
17
frontend/lib/types.ts
Normal file
17
frontend/lib/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface Monitor {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
frequency: number
|
||||
status: 'active' | 'paused' | 'error'
|
||||
last_checked_at?: string
|
||||
last_changed_at?: string
|
||||
consecutive_errors: number
|
||||
recentSnapshots?: {
|
||||
id: string
|
||||
responseTime: number
|
||||
importanceScore?: number
|
||||
changed: boolean
|
||||
createdAt: string
|
||||
}[]
|
||||
}
|
||||
43
frontend/lib/use-plan.ts
Normal file
43
frontend/lib/use-plan.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getAuth } from './auth'
|
||||
|
||||
export type UserPlan = 'free' | 'pro' | 'business' | 'enterprise'
|
||||
|
||||
export const PLAN_LIMITS = {
|
||||
free: {
|
||||
maxMonitors: 3,
|
||||
minFrequency: 60, // minutes
|
||||
features: ['email_alerts', 'basic_noise_filtering'],
|
||||
},
|
||||
pro: {
|
||||
maxMonitors: 20,
|
||||
minFrequency: 5,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export'],
|
||||
},
|
||||
business: {
|
||||
maxMonitors: 100,
|
||||
minFrequency: 1,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members'],
|
||||
},
|
||||
enterprise: {
|
||||
maxMonitors: Infinity,
|
||||
minFrequency: 1,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members', 'custom_integrations', 'sla'],
|
||||
},
|
||||
} as const
|
||||
|
||||
export function usePlan() {
|
||||
const auth = getAuth()
|
||||
const plan = (auth?.user?.plan as UserPlan) || 'free'
|
||||
const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free
|
||||
|
||||
return {
|
||||
plan,
|
||||
limits,
|
||||
canUseSlack: limits.features.includes('slack_integration' as any),
|
||||
canUseWebhook: limits.features.includes('webhook_integration' as any),
|
||||
canUseKeywords: limits.features.includes('keyword_alerts' as any),
|
||||
canUseSmartNoise: limits.features.includes('smart_noise_filtering' as any),
|
||||
maxMonitors: limits.maxMonitors,
|
||||
minFrequency: limits.minFrequency,
|
||||
}
|
||||
}
|
||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002',
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
env: {
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002',
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
12511
frontend/package-lock.json
generated
12511
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,38 @@
|
||||
{
|
||||
"name": "website-monitor-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"zod": "^3.22.4",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"react-diff-viewer-continued": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.33",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "website-monitor-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3021",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.9",
|
||||
"axios": "^1.6.5",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"framer-motion": "^12.27.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,55 +1,85 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: 'hsl(var(--success))',
|
||||
foreground: 'hsl(var(--success-foreground))',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: 'hsl(var(--warning))',
|
||||
foreground: 'hsl(var(--warning-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
from: { opacity: '0', transform: 'translateY(8px)' },
|
||||
to: { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideIn: {
|
||||
from: { opacity: '0', transform: 'translateX(-16px)' },
|
||||
to: { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
pulseGlow: {
|
||||
'0%, 100%': { boxShadow: '0 0 4px rgba(196, 178, 156, 0.4)' },
|
||||
'50%': { boxShadow: '0 0 12px rgba(196, 178, 156, 0.7)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out forwards',
|
||||
'slide-in': 'slideIn 0.3s ease-out forwards',
|
||||
'pulse-glow': 'pulseGlow 2s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user