gitea +
This commit is contained in:
213
frontend/app/admin/page.tsx
Normal file
213
frontend/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
frontend/app/blog/page.tsx
Normal file
32
frontend/app/blog/page.tsx
Normal 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
BIN
frontend/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 982 KiB |
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
61
frontend/app/privacy/page.tsx
Normal file
61
frontend/app/privacy/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user