diff --git a/backend/package-lock.json b/backend/package-lock.json index 2e13c95..fc0b633 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,10 +9,10 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "axios": "^1.6.5", + "axios": "^1.13.2", "bcryptjs": "^2.4.3", "bullmq": "^5.1.0", - "cheerio": "^1.0.0-rc.12", + "cheerio": "^1.1.2", "cors": "^2.8.5", "diff": "^5.1.0", "dotenv": "^16.3.1", diff --git a/backend/package.json b/backend/package.json index 2f0fe23..36c613f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,10 +19,10 @@ "author": "", "license": "MIT", "dependencies": { - "axios": "^1.6.5", + "axios": "^1.13.2", "bcryptjs": "^2.4.3", "bullmq": "^5.1.0", - "cheerio": "^1.0.0-rc.12", + "cheerio": "^1.1.2", "cors": "^2.8.5", "diff": "^5.1.0", "dotenv": "^16.3.1", diff --git a/backend/src/index.ts b/backend/src/index.ts index 391d19c..de68d26 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,12 +42,14 @@ app.get('/health', async (_req, res) => { import testRoutes from './routes/test'; import waitlistRoutes from './routes/waitlist'; +import { toolsRouter } from './routes/tools'; // Routes app.use('/api/auth', authLimiter, authRoutes); app.use('/api/monitors', authMiddleware, monitorRoutes); app.use('/api/settings', authMiddleware, settingsRoutes); app.use('/api/waitlist', waitlistRoutes); // Public route - no auth required +app.use('/api/tools', toolsRouter); // Public tools app.use('/test', testRoutes); // 404 handler diff --git a/backend/src/routes/tools.ts b/backend/src/routes/tools.ts new file mode 100644 index 0000000..a656a19 --- /dev/null +++ b/backend/src/routes/tools.ts @@ -0,0 +1,68 @@ +import { Router } from 'express'; +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import { z } from 'zod'; + +const router = Router(); + +const previewSchema = z.object({ + url: z.string().min(1) +}); + +router.post('/meta-preview', async (req, res) => { + try { + let { url } = previewSchema.parse(req.body); + + // Add protocol if missing + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = `https://${url}`; + } + + const response = await axios.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; WebsiteMonitorBot/1.0; +https://websitemonitor.com)' + }, + timeout: 5000, + validateStatus: (status) => status < 500 // Resolve even if 404/403 to avoid crashing flow immediately + }); + + const html = response.data; + const $ = cheerio.load(html); + + const title = $('title').text() || $('meta[property="og:title"]').attr('content') || ''; + const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || ''; + + // Attempt to find favicon + let favicon = ''; + const linkIcon = $('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]').attr('href'); + if (linkIcon) { + if (linkIcon.startsWith('http')) { + favicon = linkIcon; + } else if (linkIcon.startsWith('//')) { + favicon = `https:${linkIcon}`; + } else { + const urlObj = new URL(url); + favicon = `${urlObj.protocol}//${urlObj.host}${linkIcon.startsWith('/') ? '' : '/'}${linkIcon}`; + } + } else { + const urlObj = new URL(url); + favicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`; + } + + res.json({ + title: title.trim(), + description: description.trim(), + favicon, + url: url + }); + + } catch (error) { + console.error('Meta preview error:', error); + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Invalid URL provided' }); + } + res.status(500).json({ error: 'Failed to fetch page metadata' }); + } +}); + +export const toolsRouter = router; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 7d3b643..ecf5461 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,19 +1,42 @@ 'use client' import { useEffect, useState } from 'react' +import dynamic from 'next/dynamic' import Link from 'next/link' import { Button } from '@/components/ui/button' import { ThemeToggle } from '@/components/ui/ThemeToggle' -import { HeroSection, UseCaseShowcase, HowItWorks, Differentiators, SocialProof, FinalCTA } from '@/components/landing/LandingSections' -import { LiveStatsBar } from '@/components/landing/LiveStatsBar' -import { PricingComparison } from '@/components/landing/PricingComparison' -import { SectionDivider } from '@/components/landing/MagneticElements' +import { HeroSection } from '@/components/landing/LandingSections' import { motion, AnimatePresence } from 'framer-motion' import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react' +// Dynamic imports for performance optimization (lazy loading) +const UseCaseShowcase = dynamic( + () => import('@/components/landing/LandingSections').then(mod => ({ default: mod.UseCaseShowcase })), + { ssr: false } +) + +const HowItWorks = dynamic( + () => import('@/components/landing/LandingSections').then(mod => ({ default: mod.HowItWorks })), + { ssr: false } +) + +const Differentiators = dynamic( + () => import('@/components/landing/LandingSections').then(mod => ({ default: mod.Differentiators })), + { ssr: false } +) + +const FinalCTA = dynamic( + () => import('@/components/landing/LandingSections').then(mod => ({ default: mod.FinalCTA })), + { ssr: false } +) + +const LiveSerpPreview = dynamic( + () => import('@/components/landing/LiveSerpPreview').then(mod => ({ default: mod.LiveSerpPreview })), + { ssr: false } +) + export default function Home() { const [openFaq, setOpenFaq] = useState(null) - const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly') const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [scrollProgress, setScrollProgress] = useState(0) @@ -62,7 +85,7 @@ export default function Home() {
@@ -96,7 +119,7 @@ export default function Home() { >
setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features - setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Pricing + setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Use Cases - -
-
- -
- {/* Starter Plan */} - -

Starter

-

Perfect for side projects

-
- $0 - /mo -
-
    -
  • -
    - -
    - 3 monitors -
  • -
  • -
    - -
    - Hourly checks -
  • -
  • -
    - -
    - Email alerts -
  • -
- -
- - {/* Pro Plan */} - -
- MOST POPULAR -
-

Pro

-

For serious businesses

-
- ${billingPeriod === 'monthly' ? '29' : '24'} - /mo -
-
    -
  • -
    - -
    - 50 monitors -
  • -
  • -
    - -
    - 1-minute checks -
  • -
  • -
    - -
    - All alert channels (Slack/SMS) -
  • -
  • -
    - -
    - SSL monitoring -
  • -
- -
- - {/* Enterprise Plan */} - -

Enterprise

-

Custom solutions

-
- Custom -
-
    -
  • -
    - -
    - Unlimited monitors -
  • -
  • -
    - -
    - 30-second checks -
  • -
  • -
    - -
    - SSO & SAML -
  • -
  • -
    - -
    - Dedicated support -
  • -
- -
-
- - - {/* FAQ Section */} < section id="faq" className="border-t border-border/40 py-24 bg-background" >
@@ -373,7 +227,7 @@ export default function Home() {

Product

diff --git a/frontend/components/landing/LandingSections.tsx b/frontend/components/landing/LandingSections.tsx index 51f6cdf..b51c70e 100644 --- a/frontend/components/landing/LandingSections.tsx +++ b/frontend/components/landing/LandingSections.tsx @@ -264,8 +264,7 @@ function NoiseToSignalVisual() { - {/* Background Pattern - Subtle dots for light theme */} -
- -
- {/* Section Header */} - - - Built for teams who need results,{' '} - not demos. - - - - {/* Testimonial Cards - Light Theme */} -
- {testimonials.map((testimonial, i) => ( - - {/* Card Container */} -
- - {/* Quote Mark */} -
- " -
- - {/* Quote */} -

- {testimonial.quote} -

- - {/* Attribution */} -
-
-

{testimonial.author}

-

{testimonial.role} at {testimonial.company}

-
-
- {testimonial.useCase} -
-
-
-
- ))} -
- - {/* Note */} - - Join our waitlist to become a beta tester and get featured here. - -
- - ) -} - // ============================================ // 6. FINAL CTA - Get Started // ============================================ @@ -827,7 +722,7 @@ export function FinalCTA() { rotate: [0, 180, 360] }} transition={{ duration: 20, repeat: Infinity, ease: "linear" }} - className="absolute top-1/4 -left-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary))] blur-[140px]" + className="absolute top-1/4 -left-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary))] blur-[60px]" />
@@ -868,15 +763,6 @@ export function FinalCTA() { custom={3} className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground" > -
- - 500+ joined this week -
-
Early access: 50% off for 6 months diff --git a/frontend/components/landing/LiveSerpPreview.tsx b/frontend/components/landing/LiveSerpPreview.tsx new file mode 100644 index 0000000..4216aed --- /dev/null +++ b/frontend/components/landing/LiveSerpPreview.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { Search, Loader2, Globe, AlertCircle, ArrowRight } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' + +interface PreviewData { + title: string + description: string + favicon: string + url: string +} + +export function LiveSerpPreview() { + const [url, setUrl] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [data, setData] = useState(null) + const [error, setError] = useState('') + + const handleAnalyze = async (e: React.FormEvent) => { + e.preventDefault() + if (!url) return + + setIsLoading(true) + setError('') + setData(null) + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/tools/meta-preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }) + + if (!response.ok) throw new Error('Failed to fetch preview') + + const result = await response.json() + setData(result) + } catch (err) { + setError('Could not analyze this URL. Please check if it represents a valid, publicly accessible website.') + } finally { + setIsLoading(false) + } + } + + return ( +
+ {/* Background Gradients */} +
+ +
+
+
+ + Free Tool +
+

+ See how Google sees you +

+

+ Enter your URL to get an instant SERP preview. +

+
+ +
+ {/* Input Form */} +
+
+
+
+
+ +
+ setUrl(e.target.value)} + placeholder="website.com" + className="pl-9 h-12 bg-transparent border-none shadow-none focus-visible:ring-0 text-base" + /> +
+ +
+ + + {/* Error Message */} + + {error && ( + + + {error} + + )} + + + {/* Result Preview */} + + {data && ( + + {/* Google Result Card */} +
+
+
+ {data.favicon ? ( + Favicon + ) : ( + + )} +
+
+ + {new URL(data.url).hostname} + + + {data.url} + +
+
+

+ {data.title} +

+

+ {data.description} +

+
+ + {/* Upsell / CTA */} + +
+

+ Want to know when this changes? +

+ +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/frontend/components/landing/LiveStatsBar.tsx b/frontend/components/landing/LiveStatsBar.tsx deleted file mode 100644 index 7e47f2b..0000000 --- a/frontend/components/landing/LiveStatsBar.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' -import { useState, useEffect } from 'react' -import { Activity, TrendingUp, Zap, Shield } from 'lucide-react' - -function AnimatedNumber({ value, suffix = '' }: { value: number; suffix?: string }) { - const [displayValue, setDisplayValue] = useState(0) - - useEffect(() => { - const duration = 2000 // 2 seconds - const steps = 60 - const increment = value / steps - const stepDuration = duration / steps - - let currentStep = 0 - const interval = setInterval(() => { - currentStep++ - if (currentStep <= steps) { - setDisplayValue(Math.floor(increment * currentStep)) - } else { - setDisplayValue(value) - clearInterval(interval) - } - }, stepDuration) - - return () => clearInterval(interval) - }, [value]) - - return ( - - {displayValue.toLocaleString()}{suffix} - - ) -} - -function FluctuatingNumber({ base, variance }: { base: number; variance: number }) { - const [value, setValue] = useState(base) - - useEffect(() => { - const interval = setInterval(() => { - const fluctuation = (Math.random() - 0.5) * variance - setValue(base + fluctuation) - }, 1500) - - return () => clearInterval(interval) - }, [base, variance]) - - return ( - - {Math.round(value)}ms - - ) -} - -export function LiveStatsBar() { - const stats = [ - { - icon: , - label: 'Checks performed today', - value: 2847, - type: 'counter' as const - }, - { - icon: , - label: 'Changes detected this hour', - value: 127, - type: 'counter' as const - }, - { - icon: , - label: 'Uptime', - value: '99.9%', - type: 'static' as const - }, - { - icon: , - label: 'Avg response time', - value: '< ', - type: 'fluctuating' as const, - base: 42, - variance: 10 - } - ] - - return ( -
-
- {/* Desktop: Grid */} -
- {stats.map((stat, i) => ( - - {/* Icon */} - - {stat.icon} - - - {/* Value */} -
- {stat.type === 'counter' && typeof stat.value === 'number' && ( - - )} - {stat.type === 'static' && ( - - {stat.value} - - )} - {stat.type === 'fluctuating' && stat.base && stat.variance && ( - - {stat.value} - - )} -
- - {/* Label */} -

- {stat.label} -

-
- ))} -
- - {/* Mobile: Horizontal Scroll */} -
-
- {stats.map((stat, i) => ( - - {/* Icon */} -
- {stat.icon} -
- - {/* Value */} -
- {stat.type === 'counter' && typeof stat.value === 'number' && ( - - )} - {stat.type === 'static' && ( - - {stat.value} - - )} - {stat.type === 'fluctuating' && stat.base && stat.variance && ( - - {stat.value} - - )} -
- - {/* Label */} -

- {stat.label} -

-
- ))} -
-
-
-
- ) -} diff --git a/frontend/components/landing/PricingComparison.tsx b/frontend/components/landing/PricingComparison.tsx deleted file mode 100644 index f92760d..0000000 --- a/frontend/components/landing/PricingComparison.tsx +++ /dev/null @@ -1,255 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' -import { useState } from 'react' -import { TrendingDown, DollarSign } from 'lucide-react' - -export function PricingComparison() { - const [monitorCount, setMonitorCount] = useState(50) - - // Pricing calculation logic - const calculatePricing = (monitors: number) => { - // Competitors: tiered pricing - let competitorMin, competitorMax - if (monitors <= 10) { - competitorMin = 29 - competitorMax = 49 - } else if (monitors <= 50) { - competitorMin = 79 - competitorMax = 129 - } else if (monitors <= 100) { - competitorMin = 129 - competitorMax = 199 - } else { - competitorMin = 199 - competitorMax = 299 - } - - // Our pricing: simpler, fairer - let ourPrice - if (monitors <= 10) { - ourPrice = 19 - } else if (monitors <= 50) { - ourPrice = 49 - } else if (monitors <= 100) { - ourPrice = 89 - } else { - ourPrice = 149 - } - - const competitorAvg = (competitorMin + competitorMax) / 2 - const savings = competitorAvg - ourPrice - const savingsPercent = Math.round((savings / competitorAvg) * 100) - - return { - competitorMin, - competitorMax, - competitorAvg, - ourPrice, - savings, - savingsPercent - } - } - - const pricing = calculatePricing(monitorCount) - - return ( -
- {/* Background Pattern - Enhanced Dot Grid */} -
-
-
- -
- {/* Section Header */} - -
- - Fair Pricing -
-

- See how much you{' '} - save -

-

- Compare our transparent pricing with typical competitors. No hidden fees, no surprises. -

-
- - {/* Interactive Comparison Card */} - - {/* Monitor Count Slider */} -
-
- - - {monitorCount} - -
- - {/* Slider */} -
- setMonitorCount(Number(e.target.value))} - className="w-full h-3 bg-secondary rounded-full appearance-none cursor-pointer - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6 - [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[hsl(var(--teal))] - [&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:cursor-grab - [&::-webkit-slider-thumb]:active:cursor-grabbing [&::-webkit-slider-thumb]:hover:scale-110 - [&::-webkit-slider-thumb]:transition-transform - [&::-moz-range-thumb]:w-6 [&::-moz-range-thumb]:h-6 - [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[hsl(var(--teal))] - [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-lg - [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing" - /> - {/* Tick marks - positioned by percentage based on slider range (5-200) */} -
- 5 - 50 - 100 - 200 -
-
-
- - {/* Price Comparison Bars */} -
- {/* Competitors */} - -
- - Typical Competitors - -
- - {/* Bar */} - - -
-
- - ${pricing.competitorMin}-{pricing.competitorMax} - -
per month
-
-
-
-
- - {/* Us */} - -
- - Our Pricing - -
- - {/* Bar */} - - -
-
- - ${pricing.ourPrice} - -
per month
-
-
-
-
-
- - {/* Savings Badge */} - - -
-
You save
-
- - ${Math.round(pricing.savings)} - - /month - - {pricing.savingsPercent}% off - -
-
-
- - {/* Fine Print */} -

- * Based on average pricing from Visualping, Distill.io, and similar competitors as of Jan 2026 -

-
-
-
- ) -} diff --git a/frontend/components/landing/WaitlistForm.tsx b/frontend/components/landing/WaitlistForm.tsx index 0f187dd..9bb638c 100644 --- a/frontend/components/landing/WaitlistForm.tsx +++ b/frontend/components/landing/WaitlistForm.tsx @@ -1,8 +1,8 @@ 'use client' import { motion, AnimatePresence } from 'framer-motion' -import { useState, useEffect } from 'react' -import { Check, ArrowRight, Loader2, Sparkles } from 'lucide-react' +import { useState } from 'react' +import { Check, ArrowRight, Loader2 } from 'lucide-react' import { Button } from '@/components/ui/button' export function WaitlistForm() { @@ -10,7 +10,6 @@ export function WaitlistForm() { const [isSubmitting, setIsSubmitting] = useState(false) const [isSuccess, setIsSuccess] = useState(false) const [error, setError] = useState('') - const [queuePosition, setQueuePosition] = useState(0) const [confetti, setConfetti] = useState>([]) const validateEmail = (email: string) => { @@ -62,7 +61,6 @@ export function WaitlistForm() { const data = await response.json() if (data.success) { - setQueuePosition(data.position || Math.floor(Math.random() * 500) + 430) setIsSubmitting(false) setIsSuccess(true) triggerConfetti() @@ -154,24 +152,6 @@ export function WaitlistForm() { Check your inbox for confirmation - {/* Queue Position */} - - -
-
- Your position -
-
- #{queuePosition} -
-
-
- {/* Bonus Badge */}