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

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