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>

View File

@@ -0,0 +1,34 @@
'use client'
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, Suspense } from "react"
import { usePostHog } from 'posthog-js/react'
function PostHogPageViewContent() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
useEffect(() => {
// Track pageview
if (pathname && posthog) {
let url = window.origin + pathname
if (searchParams.toString()) {
url = url + `?${searchParams.toString()}`
}
posthog.capture('$pageview', {
'$current_url': url,
})
}
}, [pathname, searchParams, posthog])
return null
}
export default function PostHogPageView() {
return (
<Suspense fallback={null}>
<PostHogPageViewContent />
</Suspense>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
import PostHogPageView from './PostHogPageView'
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (typeof window !== 'undefined' && !posthog.__loaded) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || 'phc_placeholder_key', {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
capture_pageview: false, // Disable automatic pageview capture, as we handle it manually
capture_pageleave: true,
persistence: 'localStorage+cookie',
opt_out_capturing_by_default: true,
debug: true,
})
}
}, [])
return <PHProvider client={posthog}>
<PostHogPageView />
{children}
</PHProvider>
}

View File

@@ -0,0 +1,66 @@
'use client'
import { useEffect, useState } from 'react'
import posthog from 'posthog-js'
import { Button } from '@/components/ui/button'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import { Cookie } from 'lucide-react'
export function CookieBanner() {
const [show, setShow] = useState(false)
useEffect(() => {
const optedIn = posthog.has_opted_in_capturing()
const optedOut = posthog.has_opted_out_capturing()
if (!optedIn && !optedOut) {
setShow(true)
}
}, [])
const handleAccept = () => {
posthog.opt_in_capturing()
setShow(false)
}
const handleDecline = () => {
posthog.opt_out_capturing()
setShow(false)
}
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="fixed bottom-4 right-4 z-[100] max-w-sm w-full p-4"
>
<div className="rounded-xl border border-border bg-background/95 p-6 shadow-2xl backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="flex items-start gap-4">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Cookie className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-2">We value your privacy</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
We use cookies to enhance your browsing experience and analyze our traffic. By clicking "Accept", you consent to our use of cookies.
Read our <Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link>.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={handleDecline} className="flex-1">
Decline
</Button>
<Button onClick={handleAccept} className="flex-1">
Accept
</Button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -36,9 +36,9 @@ export function CompetitorDemoVisual() {
<motion.div
className="p-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 relative overflow-hidden shadow-xl"
animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a',
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1
? '0 0 20px rgba(239, 68, 68, 0.2)'
? '0 0 20px hsl(var(--burgundy) / 0.2)'
: '0 1px 3px rgba(0,0,0,0.5)'
}}
transition={{ duration: 0.5 }}
@@ -49,7 +49,7 @@ export function CompetitorDemoVisual() {
initial={{ x: '-100%', skewX: -20 }}
animate={{ x: '200%' }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="absolute inset-0 bg-gradient-to-r from-transparent via-red-500/10 to-transparent"
className="absolute inset-0 bg-gradient-to-r from-transparent via-[hsl(var(--burgundy))]/10 to-transparent"
/>
)}
@@ -67,7 +67,7 @@ export function CompetitorDemoVisual() {
className="text-3xl font-bold"
animate={{
textDecoration: phase === 1 ? 'line-through' : 'none',
color: phase === 1 ? '#ef4444' : '#f4f4f5'
color: phase === 1 ? 'hsl(var(--burgundy))' : '#f4f4f5'
}}
>
$99
@@ -84,14 +84,14 @@ export function CompetitorDemoVisual() {
transition={{ delay: 0.1, type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-3 mt-1"
>
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-red-500/10">
<ArrowDown className="h-4 w-4 text-red-500" strokeWidth={3} />
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-[hsl(var(--burgundy))]/10">
<ArrowDown className="h-4 w-4 text-[hsl(var(--burgundy))]" strokeWidth={3} />
</div>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-extrabold text-[#ff0000] tracking-tight">
<span className="text-5xl font-extrabold text-[hsl(var(--burgundy))] tracking-tight">
$79
</span>
<span className="text-sm font-medium text-red-500">/month</span>
<span className="text-sm font-medium text-[hsl(var(--burgundy))]">/month</span>
</div>
</motion.div>
)}
@@ -102,9 +102,9 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, scale: 0.8, rotate: -3 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ delay: 0.3, type: 'spring' }}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-500/10 border border-red-500/20"
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/20"
>
<span className="text-[10px] font-extrabold text-red-500 uppercase tracking-wider">
<span className="text-[10px] font-extrabold text-[hsl(var(--burgundy))] uppercase tracking-wider">
Save $240/year
</span>
</motion.div>
@@ -119,17 +119,17 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.6 }}
className="flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<div className="relative flex-shrink-0">
<Bell className="h-3 w-3 text-red-500" />
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
<motion.span
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1, repeat: Infinity }}
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-red-500"
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
/>
</div>
<span className="text-[9px] font-semibold text-red-500">
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
Alert sent to your team
</span>
</motion.div>

View File

@@ -115,7 +115,7 @@ export function HeroSection() {
custom={4}
className="w-full max-w-lg"
>
<WaitlistForm />
<WaitlistForm id="waitlist-form" />
</motion.div>
{/* Trust Signals */}
@@ -136,7 +136,7 @@ export function HeroSection() {
<span></span>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current" />
<span>Early access bonus</span>
<span>Early access</span>
</div>
</motion.div>
</motion.div>
@@ -765,7 +765,7 @@ export function FinalCTA() {
>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
<span>Early access</span>
</div>
</motion.div>
</motion.div>

View File

@@ -161,7 +161,7 @@ export function LiveSerpPreview() {
<Button
variant="outline"
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10"
onClick={() => document.getElementById('waitlist-form')?.scrollIntoView({ behavior: 'smooth' })}
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
>
Get notified on changes
<ArrowRight className="ml-2 h-4 w-4" />

View File

@@ -39,9 +39,9 @@ export function PolicyDemoVisual() {
<motion.div
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a',
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1
? '0 0 20px rgba(239, 68, 68, 0.2)'
? '0 0 20px hsl(var(--burgundy) / 0.2)'
: '0 1px 3px rgba(0,0,0,0.2)'
}}
transition={{ duration: 0.5 }}
@@ -63,10 +63,10 @@ export function PolicyDemoVisual() {
>
<motion.p
animate={{
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
paddingLeft: phase === 1 ? '4px' : '0px',
paddingRight: phase === 1 ? '4px' : '0px',
color: phase === 1 ? '#ef4444' : 'inherit',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit',
fontWeight: phase === 1 ? 600 : 400
}}
transition={{ duration: 0.4 }}
@@ -91,7 +91,7 @@ export function PolicyDemoVisual() {
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4 }}
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-red-500 rounded-full origin-left"
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
/>
)}
</motion.div>
@@ -114,7 +114,7 @@ export function PolicyDemoVisual() {
+18 words
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
<span className="w-2 h-2 rounded bg-[hsl(var(--burgundy))]/20 border border-[hsl(var(--burgundy))]" />
-7 words
</span>
</div>
@@ -128,16 +128,16 @@ export function PolicyDemoVisual() {
initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.5 }}
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white">
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--burgundy))] text-white">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
<div className="flex-1">
<div className="text-[9px] font-bold text-red-500">
<div className="text-[9px] font-bold text-[hsl(var(--burgundy))]">
Audit trail saved
</div>
<div className="text-[8px] text-red-500/80">
<div className="text-[8px] text-[hsl(var(--burgundy))]/80">
Snapshot archived for compliance
</div>
</div>

View File

@@ -59,10 +59,10 @@ export function SEODemoVisual() {
<motion.div
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
animate={{
borderColor: phase === 0 ? '#27272a' : '#ef4444',
borderColor: phase === 0 ? '#27272a' : 'hsl(var(--burgundy))',
boxShadow: phase === 0
? '0 1px 3px rgba(0,0,0,0.2)'
: '0 0 20px rgba(239, 68, 68, 0.2)'
: '0 0 20px hsl(var(--burgundy) / 0.2)'
}}
transition={{ duration: 0.5 }}
>
@@ -86,8 +86,8 @@ export function SEODemoVisual() {
>
<motion.span
animate={{
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
color: phase === 1 ? '#ef4444' : 'inherit'
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit'
}}
transition={{ duration: 0.5 }}
className="inline-block rounded px-0.5"
@@ -100,7 +100,7 @@ export function SEODemoVisual() {
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-red-500 text-[8px] font-bold text-white"
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
>
Changed
</motion.span>

View File

@@ -5,7 +5,11 @@ import { useState } from 'react'
import { Check, ArrowRight, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function WaitlistForm() {
interface WaitlistFormProps {
id?: string
}
export function WaitlistForm({ id }: WaitlistFormProps) {
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
@@ -160,7 +164,7 @@ export function WaitlistForm() {
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
>
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
🎉 Early access: 50% off for 6 months
🎉 Early access
</span>
</motion.div>
</motion.div>
@@ -170,65 +174,69 @@ export function WaitlistForm() {
return (
<motion.form
id={id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
onSubmit={handleSubmit}
className="max-w-md mx-auto"
>
<div className="flex flex-col sm:flex-row gap-3">
{/* Email Input */}
<motion.div
className="flex-1 relative"
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
transition={{ duration: 0.4 }}
>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
placeholder="Enter your email"
disabled={isSubmitting}
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
{/* Email Input */}
<motion.div
className="flex-1 relative"
>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
placeholder="Enter your email"
disabled={isSubmitting}
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
} disabled:opacity-50 disabled:cursor-not-allowed`}
/>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
>
{error}
</motion.div>
)}
</AnimatePresence>
</motion.div>
} disabled:opacity-50 disabled:cursor-not-allowed`}
/>
</motion.div>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting || !email}
size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining...
</>
) : (
<>
Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting || !email}
size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining...
</>
) : (
<>
Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
</div>
{/* Error Message - Visibility Improved */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="text-red-600 bg-red-50 px-4 py-2 rounded-lg text-sm font-medium border border-red-100 flex items-center gap-2"
>
<div className="h-1.5 w-1.5 rounded-full bg-red-500 flex-shrink-0" />
{error}
</motion.div>
)}
</Button>
</AnimatePresence>
</div>
{/* Trust Signals Below Form */}

View File

@@ -0,0 +1,65 @@
import Link from 'next/link'
import Image from 'next/image'
import { Globe } from 'lucide-react'
export function Footer() {
return (
<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="relative h-8 w-8">
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
</div>
<span className="text-lg font-bold text-foreground">Alertify</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="/blog" className="hover:text-primary transition-colors">Blog</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="/privacy" className="hover:text-primary transition-colors">Privacy</Link></li>
<li><Link href="/admin" className="hover:text-primary transition-colors opacity-50 text-xs">Admin</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 Alertify. 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>
)
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
@@ -86,8 +86,15 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
},
})
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Default to stored user plan from localStorage if API fails or is loading
const getStoredPlan = () => {
if (!mounted) return 'free'
if (typeof window !== 'undefined') {
try {
const userStr = localStorage.getItem('user');
@@ -98,8 +105,8 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
}
// Capitalize plan name
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
const currentPlan = settingsData?.plan || getStoredPlan() || 'free'
const planName = currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1);
// Determine badge color
const getBadgeVariant = (plan: string) => {

View File

@@ -0,0 +1,101 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Sparkline } from '@/components/sparkline'
interface Props {
monitorId: string
keywords: string[]
}
export function SEORankingCard({ monitorId, keywords }: Props) {
const { data: rankings, isLoading } = useQuery({
queryKey: ['rankings', monitorId],
queryFn: async () => {
const response = await monitorAPI.rankings(monitorId)
return response // { history: [], latest: [] }
}
})
if (isLoading) {
return (
<Card>
<CardContent className="py-6 flex justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</CardContent>
</Card>
)
}
const { latest = [], history = [] } = rankings || {}
// Group history by keyword for sparklines
const historyByKeyword = (history as any[]).reduce((acc, item) => {
if (!acc[item.keyword]) acc[item.keyword] = []
acc[item.keyword].push(item)
return acc
}, {} as Record<string, any[]>)
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{keywords.map(keyword => {
const latestRank = latest.find((r: any) => r.keyword === keyword)
const keywordHistory = historyByKeyword[keyword] || []
// Sort history by date asc for sparkline
const rankHistory = keywordHistory
.sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((item: any) => item.rank || 101) // Use 101 for unranked
return (
<Card key={keyword} className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex justify-between items-start">
<span className="truncate pr-2" title={keyword}>{keyword}</span>
{latestRank?.rank ? (
<Badge variant={latestRank.rank <= 3 ? 'success' : latestRank.rank <= 10 ? 'default' : 'secondary'}>
#{latestRank.rank}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Not found
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground mb-3">
{latestRank?.urlFound ? (
<a href={latestRank.urlFound} target="_blank" rel="noopener noreferrer" className="hover:underline truncate block">
{new URL(latestRank.urlFound).pathname}
</a>
) : (
<span>Not in top 100</span>
)}
</div>
{rankHistory.length > 1 && (
<div className="h-10 w-full mt-2">
{/* Simple visualization if Sparkline component accepts array */}
<Sparkline
data={rankHistory}
color={latestRank?.rank ? "#8b5cf6" : "#cbd5e1"}
height={40}
width={100}
/>
</div>
)}
<div className="mt-2 text-[10px] text-muted-foreground text-right">
Last checked: {latestRank ? new Date(latestRank.createdAt).toLocaleDateString() : 'Never'}
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -91,8 +91,8 @@ export const monitorAPI = {
return response.data;
},
check: async (id: string) => {
const response = await api.post(`/monitors/${id}/check`);
check: async (id: string, type: 'all' | 'content' | 'seo' = 'all') => {
const response = await api.post(`/monitors/${id}/check`, { type });
return response.data;
},
@@ -103,6 +103,13 @@ export const monitorAPI = {
return response.data;
},
rankings: async (id: string, limit = 100) => {
const response = await api.get(`/monitors/${id}/rankings`, {
params: { limit },
});
return response.data;
},
snapshot: async (id: string, snapshotId: string) => {
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
return response.data;

View File

@@ -4,6 +4,12 @@ export interface Monitor {
name: string
frequency: number
status: 'active' | 'paused' | 'error'
elementSelector?: string
ignoreRules?: { type: 'css' | 'regex' | 'text', value: string }[]
keywordRules?: { keyword: string, type: 'appears' | 'disappears' | 'count', threshold?: number, caseSensitive?: boolean }[]
seoKeywords?: string[]
seoInterval?: 'daily' | '2d' | 'weekly' | 'monthly' | 'off'
lastSeoCheckAt?: string
last_checked_at?: string
last_changed_at?: string
consecutive_errors: number

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,10 @@
"clsx": "^2.1.0",
"date-fns": "^3.0.6",
"framer-motion": "^12.27.0",
"jimp": "^1.6.0",
"lucide-react": "^0.303.0",
"next": "14.0.4",
"posthog-js": "^1.331.1",
"react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.0",

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

34
frontend/remove_bg.js Normal file
View File

@@ -0,0 +1,34 @@
const JimpModule = require('jimp');
console.log('Jimp exports:', Object.keys(JimpModule));
const Jimp = JimpModule.Jimp || JimpModule;
const path = require('path');
async function removeBackground(inputPath, outputPath) {
try {
const image = await Jimp.read(inputPath);
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
const red = this.bitmap.data[idx + 0];
const green = this.bitmap.data[idx + 1];
const blue = this.bitmap.data[idx + 2];
// If white (or close to white), make transparent
if (red > 240 && green > 240 && blue > 240) {
this.bitmap.data[idx + 3] = 0;
}
});
image.write(outputPath, (err) => {
if (err) {
console.error('Error writing file:', err);
} else {
console.log('Successfully removed background');
}
});
} catch (err) {
console.error('Error:', err);
}
}
const logoPath = path.join(__dirname, 'public', 'logo.png');
removeBackground(logoPath, logoPath);

24
frontend/remove_bg.py Normal file
View File

@@ -0,0 +1,24 @@
from PIL import Image
def remove_background(input_path, output_path):
try:
img = Image.open(input_path)
img = img.convert("RGBA")
datas = img.getdata()
newData = []
for item in datas:
# Change all white (also shades of whites)
# to transparent
if item[0] > 240 and item[1] > 240 and item[2] > 240:
newData.append((255, 255, 255, 0))
else:
newData.append(item)
img.putdata(newData)
img.save(output_path, "PNG")
print("Successfully removed background")
except Exception as e:
print(f"Error: {e}")
remove_background("c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png", "c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png")