This commit is contained in:
2026-01-21 08:21:19 +01:00
parent 4733e1a1cc
commit fd6e7c44e1
46 changed files with 3165 additions and 456 deletions

213
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Lock, Loader2, ArrowLeft, RefreshCw, Calendar, Mail, Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
interface Lead {
id: string
email: string
source: string
referrer: string
created_at: string
}
export default function AdminPage() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [data, setData] = useState<{ total: number; leads: Lead[] } | null>(null)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
headers: {
'x-admin-password': password
}
})
const result = await response.json()
if (result.success) {
setIsAuthenticated(true)
setData(result)
} else {
setError('Invalid password')
}
} catch (err) {
setError('Connection error')
} finally {
setIsLoading(false)
}
}
const refreshData = async () => {
setIsLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
headers: {
'x-admin-password': password
}
})
const result = await response.json()
if (result.success) setData(result)
} finally {
setIsLoading(false)
}
}
// LOGIN SCREEN
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-sm"
>
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Website
</Link>
<h1 className="text-2xl font-bold text-foreground">Admin Access</h1>
<p className="text-muted-foreground mt-2">Enter password to view waitlist</p>
</div>
<div className="bg-card border border-border rounded-xl p-6 shadow-xl">
<form onSubmit={handleLogin} className="space-y-4">
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-10 pl-10 pr-4 rounded-lg border border-border bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="Password"
autoFocus
/>
</div>
{error && (
<div className="text-sm text-red-500 font-medium text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Login'}
</Button>
</form>
</div>
</motion.div>
</div>
)
}
// DASHBOARD
return (
<div className="min-h-screen bg-secondary/10 p-6 md:p-12">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Waitlist Dashboard</h1>
<p className="text-muted-foreground mt-1">Real-time stats and signups</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={refreshData} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="ghost" onClick={() => setIsAuthenticated(false)}>
Logout
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid md:grid-cols-3 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-card border border-border p-6 rounded-xl shadow-sm"
>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Total Signups</h3>
<div className="mt-2 text-4xl font-bold text-[hsl(var(--primary))]">
{data?.total || 0}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-card border border-border p-6 rounded-xl shadow-sm"
>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Latest Signup</h3>
<div className="mt-2 text-lg font-medium text-foreground truncate">
{data?.leads[0]?.email || 'N/A'}
</div>
<div className="text-xs text-muted-foreground mt-1">
{data?.leads[0] ? new Date(data.leads[0].created_at).toLocaleString() : '-'}
</div>
</motion.div>
</div>
{/* Listings Table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-card border border-border rounded-xl shadow-sm overflow-hidden"
>
<div className="px-6 py-4 border-b border-border bg-secondary/5">
<h3 className="font-semibold text-foreground">Recent Signups</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-secondary/20 text-muted-foreground font-medium">
<tr>
<th className="px-6 py-3">Email</th>
<th className="px-6 py-3">Source</th>
<th className="px-6 py-3">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data?.leads.map((lead) => (
<tr key={lead.id} className="hover:bg-secondary/5 transition-colors">
<td className="px-6 py-4 font-medium text-foreground flex items-center gap-2">
<Mail className="h-3 w-3 text-muted-foreground" />
{lead.email}
</td>
<td className="px-6 py-4 text-muted-foreground">
{lead.source}
{lead.referrer && <span className="text-xs ml-2 opacity-70">via {new URL(lead.referrer).hostname}</span>}
</td>
<td className="px-6 py-4 text-muted-foreground whitespace-nowrap">
{new Date(lead.created_at).toLocaleString()}
</td>
</tr>
))}
{data?.leads.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-muted-foreground">
No signups yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.div>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export default function BlogPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-5xl space-y-12">
<div className="space-y-4">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground">Blog</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
Latest updates, guides, and insights from the Alertify team.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Placeholder for empty state */}
<div className="col-span-full py-12 text-center border rounded-2xl border-dashed border-border bg-secondary/5">
<p className="text-muted-foreground">No posts published yet. Stay tuned!</p>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}

BIN
frontend/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

View File

@@ -18,12 +18,15 @@ const spaceGrotesk = Space_Grotesk({
})
export const metadata: Metadata = {
title: 'Website Monitor - Track Changes on Any Website',
description: 'Monitor website changes with smart filtering and instant alerts',
title: 'Alertify - Track Changes on Any Website',
description: 'Alertify helps you track website changes in real-time. Get notified instantly when content updates.',
}
import { Toaster } from 'sonner'
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
import { CookieBanner } from '@/components/compliance/CookieBanner'
export default function RootLayout({
children,
}: {
@@ -32,8 +35,11 @@ export default function RootLayout({
return (
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
<body className={interTight.className}>
<Providers>{children}</Providers>
<Toaster richColors position="top-right" />
<PostHogProvider>
<Providers>{children}</Providers>
<CookieBanner />
<Toaster richColors position="top-right" />
</PostHogProvider>
</body>
</html>
)

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
@@ -40,30 +41,18 @@ export default function LoginPage() {
<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 className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
fill
className="object-contain"
priority
/>
</div>
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Sign in to your Website Monitor account
Sign in to your Alertify account
</CardDescription>
</CardHeader>

View File

@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
@@ -7,13 +8,17 @@ 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'
import { SEORankingCard } from '@/components/seo-ranking-card'
import { toast } from 'sonner'
export default function MonitorHistoryPage() {
const router = useRouter()
const params = useParams()
const id = params?.id as string
const [isChecking, setIsChecking] = useState(false)
const [isCheckingSeo, setIsCheckingSeo] = useState(false)
const { data: monitorData } = useQuery({
const { data: monitorData, refetch: refetchMonitor } = useQuery({
queryKey: ['monitor', id],
queryFn: async () => {
const response = await monitorAPI.get(id)
@@ -21,7 +26,7 @@ export default function MonitorHistoryPage() {
},
})
const { data: historyData, isLoading } = useQuery({
const { data: historyData, isLoading, refetch: refetchHistory } = useQuery({
queryKey: ['history', id],
queryFn: async () => {
const response = await monitorAPI.history(id)
@@ -29,6 +34,41 @@ export default function MonitorHistoryPage() {
},
})
const handleCheckNow = async (type: 'content' | 'seo' = 'content') => {
if (type === 'seo') {
if (isCheckingSeo) return
setIsCheckingSeo(true)
} else {
if (isChecking) return
setIsChecking(true)
}
try {
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
} else {
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else {
toast.success(result.snapshot?.changed ? 'Changes detected!' : 'No changes detected')
}
}
refetchMonitor()
refetchHistory()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(`Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
if (type === 'seo') {
setIsCheckingSeo(false)
} else {
setIsChecking(false)
}
}
}
if (isLoading) {
return (
<DashboardLayout>
@@ -66,6 +106,40 @@ export default function MonitorHistoryPage() {
</div>
{monitor && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow('content')}
disabled={isChecking || isCheckingSeo}
className="gap-2"
>
{isChecking ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
Check Now
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow('seo')}
disabled={isChecking || isCheckingSeo}
className="gap-2 border-purple-200 text-purple-700 hover:bg-purple-50"
>
{isCheckingSeo ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
)}
SEO Check
</Button>
)}
<Button
variant="outline"
size="sm"
@@ -105,6 +179,19 @@ export default function MonitorHistoryPage() {
</div>
</div>
{/* SEO Rankings */}
{monitor && monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4 text-purple-700">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<h2 className="text-lg font-semibold">SEO Keyword Performance</h2>
</div>
<SEORankingCard monitorId={id} keywords={monitor.seoKeywords} />
</div>
)}
{/* History List */}
<div>
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
@@ -181,7 +268,7 @@ export default function MonitorHistoryPage() {
{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>
<p className="whitespace-pre-wrap break-words leading-relaxed mt-2">{snapshot.summary}</p>
</div>
)}
</div>

View File

@@ -158,7 +158,7 @@ export default function SnapshotDetailsPage() {
{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>
<p className="text-sm text-blue-700 mt-2 whitespace-pre-wrap break-words leading-relaxed">{snapshot.summary}</p>
</div>
)}
</CardContent>

View File

@@ -87,6 +87,7 @@ export default function MonitorsPage() {
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState<string | null>(null)
const [checkingSeoId, setCheckingSeoId] = 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')
@@ -102,6 +103,8 @@ export default function MonitorsPage() {
threshold?: number
caseSensitive?: boolean
}>,
seoKeywords: [] as string[],
seoInterval: 'off',
})
const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
@@ -131,6 +134,10 @@ export default function MonitorsPage() {
if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules
}
if (newMonitor.seoKeywords.length > 0) {
payload.seoKeywords = newMonitor.seoKeywords
payload.seoInterval = newMonitor.seoInterval
}
if (editingId) {
await monitorAPI.update(editingId, payload)
@@ -146,7 +153,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
setShowAddForm(false)
setEditingId(null)
@@ -179,7 +188,9 @@ export default function MonitorsPage() {
frequency: monitor.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitor.keywordRules || []
keywordRules: monitor.keywordRules || [],
seoKeywords: monitor.seoKeywords || [],
seoInterval: monitor.seoInterval || 'off',
})
setEditingId(monitor.id)
setShowAddForm(true)
@@ -194,7 +205,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
}
@@ -223,38 +236,55 @@ export default function MonitorsPage() {
frequency: monitorData.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitorData.keywordRules as any[]
keywordRules: monitorData.keywordRules as any[],
seoKeywords: [],
seoInterval: 'off',
})
setShowTemplates(false)
setShowAddForm(true)
}
const handleCheckNow = async (id: string) => {
// Prevent multiple simultaneous checks
if (checkingId !== null) return
const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
// Prevent multiple simultaneous checks of the same type
if (type === 'seo') {
if (checkingSeoId !== null) return
setCheckingSeoId(id)
} else {
if (checkingId !== null) return
setCheckingId(id)
}
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}`)
}
})
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
// For SEO check, we might want to refresh rankings specifically if we had a way
} else {
toast.info('No changes detected')
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')
toast.error(err.response?.data?.message || `Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
setCheckingId(null)
if (type === 'seo') {
setCheckingSeoId(null)
} else {
setCheckingId(null)
}
}
}
@@ -646,6 +676,80 @@ export default function MonitorsPage() {
)}
</div>
{/* SEO Keywords Section */}
<div className="space-y-3 rounded-lg border border-purple-500/20 bg-purple-500/5 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-sm">SEO Tracking</h4>
<p className="text-xs text-muted-foreground">Track Google ranking for specific keywords</p>
</div>
<div className="flex gap-2">
<Select
label=""
value={newMonitor.seoInterval}
onChange={(e) => setNewMonitor({ ...newMonitor, seoInterval: e.target.value })}
options={[
{ value: 'off', label: 'Manual Check Only' },
{ value: 'daily', label: 'Check Daily' },
{ value: '2d', label: 'Every 2 Days' },
{ value: 'weekly', label: 'Check Weekly' },
{ value: 'monthly', label: 'Check Monthly' }
]}
className="w-40"
/>
<Button
type="button"
size="sm"
variant="outline"
className="border-purple-200 hover:bg-purple-50 hover:text-purple-700"
onClick={() => {
setNewMonitor({
...newMonitor,
seoKeywords: [...newMonitor.seoKeywords, '']
})
}}
>
+ Add Keyword
</Button>
</div>
</div>
{newMonitor.seoKeywords.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No SEO keywords configured.</p>
) : (
<div className="space-y-2">
{newMonitor.seoKeywords.map((keyword, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
value={keyword}
onChange={(e) => {
const updated = [...newMonitor.seoKeywords]
updated[index] = e.target.value
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
placeholder="e.g. best coffee in austin"
className="flex-1"
/>
<button
type="button"
onClick={() => {
const updated = newMonitor.seoKeywords.filter((_, i) => i !== index)
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
className="rounded p-2 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">
{editingId ? 'Save Changes' : 'Create Monitor'}
@@ -714,16 +818,41 @@ export default function MonitorsPage() {
</div>
{/* Stats Row */}
{/* SEO Status */}
{monitor.seoInterval && monitor.seoInterval !== 'off' && (
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-purple-50 p-3 text-center text-xs border border-purple-100">
<div>
<p className="font-semibold text-purple-700">{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}</p>
<p className="text-purple-600/80">SEO Check</p>
</div>
<div>
{monitor.lastSeoCheckAt ? (
<>
<p className="font-semibold text-purple-700">
{new Date(monitor.lastSeoCheckAt).toLocaleDateString()}
</p>
<p className="text-purple-600/80">Last SEO</p>
</>
) : (
<>
<p className="font-semibold text-purple-700">-</p>
<p className="text-purple-600/80">Last SEO</p>
</>
)}
</div>
</div>
)}
<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 ? (
{monitor.lastChangedAt ? (
<>
<p className="font-semibold text-foreground">
{new Date(monitor.last_changed_at).toLocaleDateString()}
{new Date(monitor.lastChangedAt).toLocaleDateString()}
</p>
<p className="text-muted-foreground">Last Change</p>
</>
@@ -737,9 +866,9 @@ export default function MonitorsPage() {
</div>
{/* Last Checked */}
{monitor.last_checked_at ? (
{monitor.lastCheckedAt ? (
<p className="mb-4 text-xs text-muted-foreground">
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}
</p>
) : (
<p className="mb-4 text-xs text-muted-foreground">
@@ -747,9 +876,26 @@ export default function MonitorsPage() {
</p>
)}
{/* SEO Rankings */}
{monitor.latestRankings && monitor.latestRankings.length > 0 && (
<div className="mb-4 space-y-1">
<p className="text-[10px] font-medium text-purple-600 uppercase tracking-wider">Top Rankings</p>
<div className="grid grid-cols-1 gap-1">
{monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => (
<div key={idx} className="flex items-center justify-between text-[11px] bg-purple-50/50 rounded px-2 py-1 border border-purple-100/50">
<span className="truncate max-w-[140px] text-purple-900 font-medium">{r.keyword}</span>
<Badge variant="outline" className="bg-white border-purple-200 text-purple-700 h-4 px-1 text-[9px] leading-none min-w-[30px] justify-center">
#{r.rank || '100+'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* 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">
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-primary/40 pl-2 line-clamp-2">
"{monitor.recentSnapshots[0].summary}"
</p>
)}
@@ -784,12 +930,24 @@ export default function MonitorsPage() {
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
>
{checkingSeoId === monitor.id ? 'SEO Checking...' : 'Check SEO'}
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -887,7 +1045,7 @@ export default function MonitorsPage() {
)}
<div className="text-center">
<p className="font-medium text-foreground">
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
{monitor.lastChangedAt ? new Date(monitor.lastChangedAt).toLocaleDateString() : '-'}
</p>
<p className="text-xs">Last Change</p>
</div>
@@ -898,12 +1056,25 @@ export default function MonitorsPage() {
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
title="Check SEO Rankings"
>
{checkingSeoId === monitor.id ? 'Checking SEO...' : 'SEO'}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
Edit
</Button>

View File

@@ -3,10 +3,12 @@
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { HeroSection } from '@/components/landing/LandingSections'
import { motion, AnimatePresence } from 'framer-motion'
import { Footer } from '@/components/layout/Footer'
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
// Dynamic imports for performance optimization (lazy loading)
@@ -78,10 +80,10 @@ export default function Home() {
<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 className="relative h-8 w-8 transition-transform group-hover:scale-110">
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
</div>
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
<span className="text-lg font-bold tracking-tight text-foreground">Alertify</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>
@@ -202,67 +204,7 @@ export default function Home() {
<FinalCTA />
{/* 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="#use-cases" className="hover:text-primary transition-colors">Use Cases</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 >
<Footer />
</div >
)
}

View File

@@ -0,0 +1,61 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-3xl space-y-8">
<div className="space-y-4">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
<h1 className="text-4xl font-bold font-display text-foreground">Privacy Policy</h1>
<p className="text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p>
</div>
<section className="space-y-4 prose prose-neutral dark:prose-invert max-w-none">
<h3>1. Introduction</h3>
<p>
Welcome to Alertify. We respect your privacy and are committed to protecting your personal data.
This privacy policy will inform you as to how we look after your personal data when you visit our website
and tell you about your privacy rights and how the law protects you.
</p>
<h3>2. Data We Collect</h3>
<p>
We may collect, use, store and transfer different kinds of personal data about you which we have grouped together follows:
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li>Identity Data: includes email address.</li>
<li>Technical Data: includes internet protocol (IP) address, browser type and version, time zone setting and location.</li>
<li>Usage Data: includes information about how you use our website and services.</li>
</ul>
<h3>3. How We Use Your Data</h3>
<p>
We will only use your personal data when the law allows us to. Most commonly, we will use your personal data in the following circumstances:
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li>To provide the service you signed up for (Waitlist, Monitoring).</li>
<li>To manage our relationship with you.</li>
<li>To improve our website, products/services, marketing and customer relationships.</li>
</ul>
<h3>4. Contact Us</h3>
<p>
If you have any questions about this privacy policy or our privacy practices, please contact us at:
</p>
<div className="p-4 bg-secondary/20 rounded-lg border border-border">
<p className="font-semibold">Alertify Support</p>
<p>Email: <a href="mailto:support@qrmaster.net" className="text-primary hover:underline">support@qrmaster.net</a></p>
</div>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
@@ -59,21 +60,14 @@ export default function RegisterPage() {
<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 className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
fill
className="object-contain"
priority
/>
</div>
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
<CardDescription>