This commit is contained in:
2026-01-19 08:32:44 +01:00
parent b4f6a83da0
commit 818779ab07
125 changed files with 32456 additions and 21017 deletions

View 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>
)
}

View 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 }
)
}
}

View File

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

View 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>
)
}

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View 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>
)
}

View 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 >
)
}

View File

@@ -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 &amp; 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 >
)
}

View File

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

View File

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

View 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>
)
}

View 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>
)
}

View 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>
)
}