'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 (
{icon}

{value}

{label}

{subtext &&

{subtext}

}
) } export default function MonitorsPage() { const router = useRouter() const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan() const [showAddForm, setShowAddForm] = useState(false) const [checkingId, setCheckingId] = useState(null) const [editingId, setEditingId] = useState(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 (

Loading monitors...

) } return ( {/* Stats Overview */}
} label="Total Monitors" value={stats.total} color="blue" /> } label="Active" value={stats.active} subtext="Running smoothly" color="green" /> } label="Errors" value={stats.errors} subtext="Needs attention" color="red" /> } label="Avg Uptime" value={`${stats.avgUptime}%`} subtext="Last 30 days" color="amber" />
{/* Template Selection Modal */} { showTemplates && (
Choose a Template Start with a pre-configured monitor setup
{monitorTemplates.map(template => ( ))}
) } {/* Visual Selector Modal */} { showVisualSelector && ( { setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' }) setShowVisualSelector(false) }} onClose={() => setShowVisualSelector(false)} /> ) } {/* Actions Bar */}
{/* Filter Tabs */}
{(['all', 'active', 'error'] as const).map((status) => ( ))}
{/* View Toggle */}
{/* Add/Edit Monitor Form */} { showAddForm && ( {editingId ? 'Edit Monitor' : 'Add New Monitor'} {editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
setNewMonitor({ ...newMonitor, url: e.target.value })} placeholder="https://example.com" required /> setNewMonitor({ ...newMonitor, name: e.target.value })} placeholder="My Monitor" />
{ 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" />
{newMonitor.selectedPreset === 'custom' && (
setNewMonitor({ ...newMonitor, ignoreSelector: e.target.value })} placeholder="e.g. .ad-banner, #timestamp" hint="Elements matching this selector will be ignored" /> {newMonitor.url && ( )}
)} {/* Keyword Alerts Section */}

Keyword Alerts

Get notified when specific keywords appear or disappear

{canUseKeywords && ( )}
{!canUseKeywords ? (

Pro Feature

Upgrade to Pro to track specific keywords and content changes.

) : ( <> {newMonitor.keywordRules.length === 0 ? (

No keyword alerts configured. Click "Add Keyword" to create one.

) : (
{newMonitor.keywordRules.map((rule, index) => (
{ const updated = [...newMonitor.keywordRules] updated[index].keyword = e.target.value setNewMonitor({ ...newMonitor, keywordRules: updated }) }} placeholder="e.g. hiring, sold out" />
{ const updated = [...newMonitor.keywordRules] updated[index].threshold = parseInt(e.target.value) setNewMonitor({ ...newMonitor, keywordRules: updated }) }} placeholder="Threshold" />
)}
))}
)} )}
) } {/* Monitors Grid/List */} { filteredMonitors.length === 0 ? (

{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}

{filterStatus === 'all' ? 'Start monitoring your first website to get notified of changes' : 'Try changing the filter to see other monitors'}

{filterStatus === 'all' && ( )}
) : viewMode === 'grid' ? ( /* Grid View */
{filteredMonitors.map((monitor: any) => (
{/* Monitor Info */}

{monitor.name || new URL(monitor.url).hostname}

{monitor.status}

{monitor.url}

{/* Stats Row */}

{monitor.frequency}m

Frequency

{monitor.last_changed_at ? ( <>

{new Date(monitor.last_changed_at).toLocaleDateString()}

Last Change

) : ( <>

-

Last Change

)}
{/* Last Checked */} {monitor.last_checked_at ? (

Last checked: {new Date(monitor.last_checked_at).toLocaleString()}

) : (

Not checked yet

)} {/* Change Summary */} {monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (

"{monitor.recentSnapshots[0].summary}"

)} {/* Sparkline & Importance */} {monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (

Response Time

s.responseTime).reverse()} color={monitor.status === 'error' ? '#ef4444' : '#22c55e'} height={30} width={100} />

Importance

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
)} {/* Actions */}
) )}
) : ( /* List View */
{filteredMonitors.map((monitor: any) => { return (
{/* Status Indicator */}
{/* Monitor Info */}

{monitor.name || new URL(monitor.url).hostname}

{monitor.status}

{monitor.url}

{/* Stats */}

{monitor.frequency}m

Frequency

{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? ( 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 ) : (

-

)}

Importance

{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
s.responseTime).reverse()} color={monitor.status === 'error' ? '#ef4444' : '#22c55e'} height={24} width={96} />

Response Time

)}

{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}

Last Change

{/* Actions */}
) })}
) } ) }