Production ready

This commit is contained in:
Timo Knuth 2026-02-09 22:31:22 +01:00
parent fd6e7c44e1
commit 7814548e11
82 changed files with 3390 additions and 2026 deletions

View File

@ -104,13 +104,28 @@ SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-api-key
LANDING_ONLY_MODE=false
ADMIN_PASSWORD=change-me-for-admin-waitlist
```
### Frontend (.env.local)
```env
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_LANDING_ONLY_MODE=false
```
### Landing-only mode (Waitlist rollout)
To expose only landing pages and waitlist APIs, enable both flags:
- Backend `.env`: `LANDING_ONLY_MODE=true`
- Frontend `.env.local`: `NEXT_PUBLIC_LANDING_ONLY_MODE=true`
When enabled:
- Public pages: `/`, `/blog`, `/privacy`, `/admin`
- All other frontend routes redirect to `/` with HTTP `307`
- Backend only allows `/api/waitlist/*`, `POST /api/tools/meta-preview`, and `/health`
## 📖 Usage
### Register an Account

View File

@ -164,6 +164,25 @@ MIN_FREQUENCY_FREE=60 # minutes
MIN_FREQUENCY_PRO=5 # minutes
```
### Landing-only Mode
For a waitlist-only launch:
```env
# backend/.env
LANDING_ONLY_MODE=true
ADMIN_PASSWORD=your-secure-admin-password
```
```env
# frontend/.env.local
NEXT_PUBLIC_LANDING_ONLY_MODE=true
```
With both flags enabled:
- only `/`, `/blog`, `/privacy`, `/admin` stay public
- all other frontend URLs redirect to `/` (HTTP 307)
- backend only permits `/api/waitlist/*`, `POST /api/tools/meta-preview`, and `/health`
## 📖 Learn More
- See `README.md` for complete documentation

View File

@ -22,6 +22,8 @@ SMTP_PASS=your-sendgrid-api-key
# App
APP_URL=http://localhost:3000
API_URL=http://localhost:3002
LANDING_ONLY_MODE=false
ADMIN_PASSWORD=change-me-for-admin-waitlist
# Rate Limiting
MAX_MONITORS_FREE=5

14
backend/.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"root": true,
"env": {
"node": true,
"es2022": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"ignorePatterns": ["dist", "node_modules"]
}

0
backend/nul Normal file
View File

View File

@ -49,7 +49,7 @@ export function getMaxMonitors(plan: UserPlan): number {
*/
export function hasFeature(plan: UserPlan, feature: string): boolean {
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
return planConfig.features.includes(feature as any);
return (planConfig.features as readonly string[]).includes(feature);
}
/**

View File

@ -57,6 +57,8 @@ export const getClient = () => pool.connect();
// User queries
export const db = {
query,
users: {
async create(email: string, passwordHash: string): Promise<User> {
const result = await query(
@ -455,7 +457,7 @@ export const db = {
return toCamelCase<any>(result.rows[0]);
},
async findLatestByMonitorId(monitorId: string, limit = 50): Promise<any[]> {
async findLatestByMonitorId(monitorId: string): Promise<any[]> {
// Gets the latest check per keyword for this monitor
// Using DISTINCT ON is efficient in Postgres
const result = await query(

View File

@ -10,6 +10,26 @@ import { startWorker, shutdownScheduler, getSchedulerStats } from './services/sc
const app = express();
const PORT = process.env.PORT || 3002;
const isLandingOnlyMode = process.env.LANDING_ONLY_MODE === 'true';
const isAllowedInLandingOnlyMode = (req: express.Request): boolean => {
if ((req.method === 'GET' || req.method === 'HEAD') && req.path === '/health') {
return true;
}
if (
(req.path === '/api/tools/meta-preview' || req.path === '/api/tools/meta-preview/') &&
(req.method === 'POST' || req.method === 'OPTIONS')
) {
return true;
}
if (req.path === '/api/waitlist' || req.path.startsWith('/api/waitlist/')) {
return true;
}
return false;
};
// Middleware
app.use(cors({
@ -29,6 +49,20 @@ app.use((req, _res, next) => {
next();
});
if (isLandingOnlyMode) {
app.use((req, res, next) => {
if (isAllowedInLandingOnlyMode(req)) {
return next();
}
return res.status(403).json({
error: 'landing_only_mode',
message: 'This endpoint is disabled while landing-only mode is active.',
path: req.path,
});
});
}
// Health check
app.get('/health', async (_req, res) => {
const schedulerStats = await getSchedulerStats();
@ -63,6 +97,7 @@ app.use((req, res) => {
// Error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
void _next;
console.error('Unhandled error:', err);
res.status(500).json({

View File

@ -2,6 +2,8 @@ import { Router } from 'express';
import axios from 'axios';
import * as cheerio from 'cheerio';
import { z } from 'zod';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
const router = Router();
@ -32,8 +34,8 @@ router.post('/meta-preview', async (req, res) => {
'Cache-Control': 'max-age=0'
},
timeout: 30000,
httpAgent: new (require('http').Agent)({ family: 4, keepAlive: true }),
httpsAgent: new (require('https').Agent)({ family: 4, rejectUnauthorized: false, keepAlive: true }),
httpAgent: new HttpAgent({ family: 4, keepAlive: true }),
httpsAgent: new HttpsAgent({ family: 4, rejectUnauthorized: false, keepAlive: true }),
validateStatus: (status) => status < 500
});
@ -70,9 +72,11 @@ router.post('/meta-preview', async (req, res) => {
} catch (error) {
console.error('Meta preview error:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Invalid URL provided' });
res.status(400).json({ error: 'Invalid URL provided' });
return;
}
res.status(500).json({ error: 'Failed to fetch page metadata' });
return;
}
});

View File

@ -27,12 +27,13 @@ router.post('/', async (req, res) => {
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
const position = parseInt(countResult.rows[0].count, 10);
return res.json({
res.json({
success: true,
message: 'You\'re on the list!',
position,
alreadySignedUp: true,
});
return;
}
// Insert new lead
@ -54,11 +55,12 @@ router.post('/', async (req, res) => {
});
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
res.status(400).json({
success: false,
error: 'validation_error',
message: error.errors[0].message,
});
return;
}
console.error('Waitlist signup error:', error);
@ -99,10 +101,11 @@ router.get('/admin', async (req, res) => {
const providedPassword = req.headers['x-admin-password'];
if (!adminPassword || providedPassword !== adminPassword) {
return res.status(401).json({
res.status(401).json({
success: false,
message: 'Unauthorized',
});
return;
}
// Get stats

View File

@ -1,4 +1,4 @@
import { Queue, Worker } from 'bullmq';
import { ConnectionOptions, Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import nodemailer from 'nodemailer';
import db from '../db';
@ -7,10 +7,11 @@ import db from '../db';
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
const queueConnection = redisConnection as unknown as ConnectionOptions;
// Digest queue
export const digestQueue = new Queue('change-digests', {
connection: redisConnection,
connection: queueConnection,
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 10,
@ -264,7 +265,7 @@ export function startDigestWorker(): Worker {
await processDigests(interval);
},
{
connection: redisConnection,
connection: queueConnection,
concurrency: 1,
}
);

View File

@ -1,5 +1,5 @@
import db from '../db';
import { Monitor, Snapshot } from '../types';
import { Snapshot } from '../types';
import { fetchPage } from './fetcher';
import {
applyIgnoreRules,
@ -11,6 +11,20 @@ import { calculateChangeImportance } from './importance';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
import { generateSimpleSummary, generateAISummary } from './summarizer';
import { processSeoChecks } from './seo';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
async function acquireLock(key: string, ttlMs = 120000): Promise<boolean> {
const result = await redis.set(key, '1', 'PX', ttlMs, 'NX');
return result === 'OK';
}
async function releaseLock(key: string): Promise<void> {
await redis.del(key);
}
export interface CheckResult {
snapshot: Snapshot;
@ -24,6 +38,13 @@ export async function checkMonitor(
): Promise<{ snapshot?: Snapshot; alertSent: boolean } | void> {
console.log(`[Monitor] Starting check: ${monitorId} | Type: ${checkType} | ForceSEO: ${forceSeo}`);
const lockKey = `lock:monitor-check:${monitorId}`;
const acquired = await acquireLock(lockKey);
if (!acquired) {
console.log(`[Monitor] Skipping ${monitorId} - another check is already running`);
return;
}
try {
const monitor = await db.monitors.findById(monitorId);
@ -247,6 +268,8 @@ export async function checkMonitor(
} catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId);
} finally {
await releaseLock(lockKey);
}
}

View File

@ -1,16 +1,17 @@
import { Queue, Worker, QueueEvents } from 'bullmq';
import { ConnectionOptions, Queue, Worker, QueueEvents } from 'bullmq';
import Redis from 'ioredis';
import { checkMonitor } from './monitor';
import { Monitor } from '../db';
import { Monitor } from '../types';
// Redis connection
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
const queueConnection = redisConnection as unknown as ConnectionOptions;
// Monitor check queue
export const monitorQueue = new Queue('monitor-checks', {
connection: redisConnection,
connection: queueConnection,
defaultJobOptions: {
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
@ -23,7 +24,7 @@ export const monitorQueue = new Queue('monitor-checks', {
});
// Queue events for monitoring
const queueEvents = new QueueEvents('monitor-checks', { connection: redisConnection });
const queueEvents = new QueueEvents('monitor-checks', { connection: queueConnection });
queueEvents.on('completed', ({ jobId }) => {
console.log(`[Scheduler] Job ${jobId} completed`);
@ -131,7 +132,7 @@ export function startWorker(): Worker {
}
},
{
connection: redisConnection,
connection: queueConnection,
concurrency: 5, // Process up to 5 monitors concurrently
}
);

6
backend/src/types/serpapi.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module 'serpapi' {
export function getJson(
params: Record<string, unknown>,
callback: (json: any) => void
): void;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,21 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export const metadata: Metadata = {
title: 'Blog — Website Monitoring Tips & Updates',
description:
'Guides, tutorials, and product updates from the SiteChangeMonitor team. Learn how to monitor websites effectively and reduce false alerts.',
alternates: { canonical: '/blog' },
openGraph: {
title: 'Blog — Website Monitoring Tips & Updates',
description:
'Guides, tutorials, and product updates from the SiteChangeMonitor team.',
url: '/blog',
},
}
export default function BlogPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
@ -14,7 +28,7 @@ export default function BlogPage() {
</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.
Latest updates, guides, and insights from the SiteChangeMonitor team.
</p>
</div>

View File

@ -0,0 +1,170 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
import { notFound } from 'next/navigation'
const features: Record<string, {
title: string
metaDescription: string
intro: string
howItWorks: string
vsAlternatives: string
faqs: { q: string; a: string }[]
}> = {
'noise-filtering': {
title: 'AI Noise Filtering',
metaDescription: 'SiteChangeMonitor uses AI to automatically filter out cookie banners, timestamps, rotating ads, and session IDs — delivering zero-noise website change alerts.',
intro: 'AI Noise Filtering is the core feature that sets SiteChangeMonitor apart. It automatically identifies and filters out irrelevant page changes — cookie banners, footer timestamps, rotating ads, and session-specific content — so you only receive alerts for meaningful updates.',
howItWorks: 'Our AI analyzes each page change in context. It recognizes common noise patterns (date stamps, consent popups, A/B test variations, ad rotations) and suppresses them automatically. You can also add custom ignore rules using CSS selectors, regex patterns, or plain text matching.',
vsAlternatives: 'Visualping and Distill.io require manual configuration of ignore rules, which most users skip — leading to constant false alerts. SiteChangeMonitor applies smart noise filtering by default, significantly reducing false positives without any setup.',
faqs: [
{ q: 'Does AI filtering work on all websites?', a: 'Yes. Our filtering engine recognizes common noise patterns across all site types — SPAs, e-commerce, news sites, and more. You can also add custom rules for edge cases.' },
{ q: 'Can I customize what gets filtered?', a: 'Absolutely. You can add custom ignore rules (CSS selectors, regex, text patterns) on top of the automatic AI filtering.' },
{ q: 'Will I miss important changes?', a: 'No. The AI only filters known noise patterns. Any change it cannot confidently classify as noise is passed through to you.' },
],
},
'visual-diff': {
title: 'Visual Diff & Screenshots',
metaDescription: 'See exactly what changed on any web page with side-by-side screenshot diffs. SiteChangeMonitor provides visual proof of every change.',
intro: 'Visual Diff gives you screenshot-based proof of every website change. Instead of parsing raw HTML diffs, see side-by-side visual comparisons that highlight exactly what changed on the page.',
howItWorks: 'SiteChangeMonitor captures full-page screenshots on every check. When a change is detected, we generate a visual diff that highlights modified areas. You can compare any two snapshots in your version history.',
vsAlternatives: 'Distill.io offers text-based diffs only. UptimeRobot has no diff capability. Visualping offers visual diffs but lacks AI noise filtering, so most of the changes shown are irrelevant noise.',
faqs: [
{ q: 'What format are the screenshots?', a: 'Screenshots are captured as full-page PNG images and stored with timestamps for audit purposes.' },
{ q: 'Can I compare any two versions?', a: 'Yes. You can select any two snapshots from the version history and generate a visual diff between them.' },
{ q: 'Are screenshots included in the free plan?', a: 'Yes, visual diffs are available on all plans including the Forever Free tier.' },
],
},
'keyword-monitoring': {
title: 'Keyword Monitoring',
metaDescription: 'Set keyword triggers on any web page. Get alerted when specific words appear, disappear, or cross a count threshold. SiteChangeMonitor keyword monitoring.',
intro: 'Keyword Monitoring lets you set precise triggers for when specific words or phrases appear, disappear, or cross a count threshold on any monitored page. Ideal for tracking pricing terms, product availability, job postings, and competitive messaging.',
howItWorks: 'Add one or more keyword rules to any monitor. Choose trigger type: "appears" (word added to page), "disappears" (word removed), or "count" (word frequency crosses a threshold). Supports exact match and regex patterns.',
vsAlternatives: 'UptimeRobot offers basic keyword checking but no appear/disappear logic. Distill.io supports conditions but requires complex selector configuration. SiteChangeMonitor makes keyword monitoring a first-class feature with a simple UI.',
faqs: [
{ q: 'Can I use regex for keyword matching?', a: 'Yes. You can use full regular expressions for complex pattern matching, such as price formats ($XX.XX) or phone numbers.' },
{ q: 'What is a count threshold trigger?', a: 'Count triggers alert you when a keyword appears more (or fewer) than N times on a page. Useful for tracking inventory counts or job listing volumes.' },
{ q: 'Can I combine keyword alerts with noise filtering?', a: 'Yes. Noise filtering runs first, then keyword checks run on the cleaned content — ensuring accurate keyword detection.' },
],
},
'seo-ranking': {
title: 'SEO & Ranking Alerts',
metaDescription: 'Monitor search engine ranking changes, featured snippet movements, and SERP updates. SiteChangeMonitor alerts SEO teams to ranking shifts.',
intro: 'SEO & Ranking Alerts help SEO professionals track changes in search engine results pages. Monitor your target keywords for ranking shifts, featured snippet ownership changes, and new competitor appearances.',
howItWorks: 'Point a monitor at a Google search results URL for your keyword. SiteChangeMonitor captures the SERP from a clean, non-personalized browser session and alerts you when rankings change. AI filtering removes localized variations.',
vsAlternatives: 'Dedicated SEO tools like Ahrefs and SEMrush track rankings but cost $99+/month and are built for large-scale keyword tracking. SiteChangeMonitor is ideal for focused SERP monitoring at a fraction of the cost.',
faqs: [
{ q: 'Does this replace my SEO rank tracker?', a: 'It complements rank trackers by providing instant alerts on SERP changes. Use it for your most important keywords that need real-time monitoring.' },
{ q: 'How do you handle personalized results?', a: 'We use clean, non-personalized browser sessions from consistent locations to ensure results are not skewed by personal search history.' },
{ q: 'Can I track featured snippets?', a: 'Yes. Set a keyword trigger for your brand name in the featured snippet area to know instantly when you gain or lose the snippet.' },
],
},
'multi-channel-alerts': {
title: 'Multi-Channel Alerts',
metaDescription: 'Get website change notifications via email, Slack, or webhooks. SiteChangeMonitor delivers alerts where your team works.',
intro: 'Multi-Channel Alerts deliver website change notifications where your team already works — email, Slack, or webhooks. Route different monitors to different channels based on urgency and team.',
howItWorks: 'Configure alert channels per monitor or globally. Each channel can have its own rules: immediate alerts for critical monitors, daily digests for informational ones. Webhooks support custom payloads for integration with any system.',
vsAlternatives: 'Visualping restricts Slack integration to enterprise plans. Distill.io supports basic notifications but lacks channel routing. SiteChangeMonitor includes Slack and webhook channels on Pro plans and above, with email alerts on every plan.',
faqs: [
{ q: 'Is Slack included in the free plan?', a: 'Slack and webhook integrations are available on Pro and Business plans. The free plan includes email notifications.' },
{ q: 'Can I set up digest emails?', a: 'Yes. Choose between instant alerts and daily or weekly digest emails that summarize all changes across your monitors.' },
{ q: 'Do webhooks support custom payloads?', a: 'Yes. You can customize the webhook payload format to integrate with any system — Zapier, Make, n8n, or your own API.' },
],
},
}
export function generateStaticParams() {
return Object.keys(features).map((slug) => ({ slug }))
}
export function generateMetadata({ params }: { params: { slug: string } }): Metadata {
const data = features[params.slug]
if (!data) return {}
return {
title: data.title,
description: data.metaDescription,
alternates: { canonical: `/features/${params.slug}` },
openGraph: { title: data.title, description: data.metaDescription, url: `/features/${params.slug}` },
}
}
export default function FeaturePage({ params }: { params: { slug: string } }) {
const data = features[params.slug]
if (!data) notFound()
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: data.faqs.map((faq) => ({
'@type': 'Question',
name: faq.q,
acceptedAnswer: { '@type': 'Answer', text: faq.a },
})),
}
return (
<div className="min-h-screen bg-background flex flex-col">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-4xl space-y-12">
<div className="space-y-4">
<Link href="/features" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
All Features
</Link>
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground">
{data.title}
</h1>
<p className="text-xl text-muted-foreground max-w-3xl">{data.intro}</p>
</div>
<section>
<h2 className="text-2xl font-bold text-foreground mb-4">How It Works</h2>
<p className="text-muted-foreground">{data.howItWorks}</p>
</section>
<section>
<h2 className="text-2xl font-bold text-foreground mb-4">vs. Alternatives</h2>
<p className="text-muted-foreground">{data.vsAlternatives}</p>
</section>
<section>
<h2 className="text-2xl font-bold text-foreground mb-6">FAQ</h2>
<dl className="space-y-6">
{data.faqs.map((faq, i) => (
<div key={i}>
<dt className="font-medium text-foreground">{faq.q}</dt>
<dd className="mt-1 text-muted-foreground">{faq.a}</dd>
</div>
))}
</dl>
</section>
{/* CTA */}
<section className="text-center py-12">
<h2 className="text-2xl font-bold text-foreground mb-4">Try {data.title}</h2>
<p className="text-muted-foreground mb-6">Join the waitlist for early access.</p>
<Link
href="/"
className="inline-flex items-center rounded-full bg-primary px-8 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Join the Waitlist
</Link>
</section>
{/* Internal Links */}
<nav className="flex flex-wrap gap-3 text-sm">
<Link href="/features" className="text-primary hover:underline">All Features</Link>
<span className="text-muted-foreground"></span>
<Link href="/use-cases" className="text-primary hover:underline">Use Cases</Link>
</nav>
</div>
</div>
<Footer />
</div>
)
}

View File

@ -0,0 +1,112 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export const metadata: Metadata = {
title: 'Features — AI-Powered Website Change Detection',
description:
'Explore SiteChangeMonitor features: AI noise filtering, visual diffs, keyword monitoring, SEO ranking alerts, and multi-channel notifications.',
alternates: { canonical: '/features' },
openGraph: {
title: 'Features — SiteChangeMonitor',
description: 'AI noise filtering, visual diffs, keyword alerts, and more.',
url: '/features',
},
}
const features = [
{
slug: 'noise-filtering',
title: 'AI Noise Filtering',
description: 'Automatically ignore cookie banners, timestamps, ads, and session IDs. Only get alerted on meaningful changes.',
},
{
slug: 'visual-diff',
title: 'Visual Diff & Screenshots',
description: 'See exactly what changed with side-by-side screenshot comparisons. Audit-proof visual evidence for every change.',
},
{
slug: 'keyword-monitoring',
title: 'Keyword Monitoring',
description: 'Set triggers for when specific words appear or disappear on a page. Track pricing terms, product names, or any keyword.',
},
{
slug: 'seo-ranking',
title: 'SEO & Ranking Alerts',
description: 'Monitor SERP changes, featured snippets, and competitor ranking movements for your target keywords.',
},
{
slug: 'multi-channel-alerts',
title: 'Multi-Channel Alerts',
description: 'Get notified via email, Slack, webhooks, or Teams. Route different monitors to different channels.',
},
]
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
itemListElement: features.map((f, i) => ({
'@type': 'ListItem',
position: i + 1,
name: f.title,
url: `https://sitechangemonitor.com/features/${f.slug}`,
})),
}
export default function FeaturesPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListJsonLd) }}
/>
<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">
Features
</h1>
<p className="text-xl text-muted-foreground max-w-3xl">
SiteChangeMonitor combines AI-powered noise filtering with visual diffs, keyword alerts, and multi-channel notifications to deliver zero-noise website change detection.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((f) => (
<Link
key={f.slug}
href={`/features/${f.slug}`}
className="group rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors"
>
<h2 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors">
{f.title}
</h2>
<p className="mt-2 text-muted-foreground">{f.description}</p>
<span className="mt-4 inline-block text-sm font-medium text-primary">
Learn more
</span>
</Link>
))}
</div>
<section className="text-center py-12">
<h2 className="text-2xl font-bold text-foreground mb-4">Ready to try it?</h2>
<p className="text-muted-foreground mb-6">Join the waitlist for early access to every feature.</p>
<Link
href="/"
className="inline-flex items-center rounded-full bg-primary px-8 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Join the Waitlist
</Link>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@ -105,7 +105,7 @@ export default function ForgotPasswordPage() {
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">Didn't receive an email?</p>
<p className="mb-2">{"Didn't receive an email?"}</p>
<ul className="ml-4 list-disc space-y-1">
<li>Check your spam folder</li>
<li>Make sure you entered the correct email</li>

View File

@ -73,6 +73,17 @@
/* Sehr leichtes Teal - Pricing */
--section-bg-7: 349 8% 95%;
/* Sehr leichtes Burgundy - Social Proof */
/* New Gradients - Light Mode */
--ivory-start: 40 20% 97%;
/* #FAF9F6 */
--ivory-end: 38 18% 84%;
/* #DCD7CE */
--velvet-start: 45 12% 64%;
/* #ADA996 */
--velvet-end: 0 0% 95%;
/* #F2F2F2 */
}
/* Dark theme following the warm palette aesthetic */
@ -120,6 +131,17 @@
--section-bg-5: 25 10% 9%;
--section-bg-6: 177 20% 8%;
--section-bg-7: 349 20% 9%;
/* New Gradients - Dark Mode */
--ivory-start: 0 0% 11%;
/* #1c1c1c */
--ivory-end: 30 5% 16%;
/* #2a2826 */
--velvet-start: 177 15% 20%;
/* #2d3a3a - Deep Sage */
--velvet-end: 0 0% 10%;
/* #1a1a1a */
}
}
@ -132,6 +154,7 @@
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-y: scroll;
}
body {
@ -242,6 +265,15 @@
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(200 30% 50%) 100%);
}
/* User Requested Gradients */
.gradient-ivory {
background: linear-gradient(180deg, hsl(var(--ivory-start)) 0%, hsl(var(--ivory-end)) 100%);
}
.gradient-velvet {
background: linear-gradient(180deg, hsl(var(--velvet-start)) 0%, hsl(var(--velvet-end)) 100%);
}
/* Status indicator dots */
.status-dot {
@apply w-2.5 h-2.5 rounded-full;

View File

@ -3,6 +3,16 @@ import { Inter_Tight, Space_Grotesk } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const rawSiteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://sitechangemonitor.com'
const siteUrl = (() => {
try {
return new URL(rawSiteUrl).toString().replace(/\/$/, '')
} catch {
return 'https://sitechangemonitor.com'
}
})()
// Body/UI font - straff, modern, excellent readability
const interTight = Inter_Tight({
subsets: ['latin'],
@ -18,8 +28,42 @@ const spaceGrotesk = Space_Grotesk({
})
export const metadata: Metadata = {
title: 'Alertify - Track Changes on Any Website',
description: 'Alertify helps you track website changes in real-time. Get notified instantly when content updates.',
metadataBase: new URL(siteUrl),
title: {
default: 'Website Change Monitor | SiteChangeMonitor',
template: '%s | SiteChangeMonitor',
},
description:
'Website change monitor for SEO and growth teams. Monitor website changes, track competitor price updates, and get visual diff alerts with less noise.',
keywords: [
'website change monitor',
'monitor website changes',
'track page changes',
'competitor price tracker',
'visual diff alert',
'website change detection',
],
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
url: '/',
title: 'Website Change Monitor | SiteChangeMonitor',
description:
'Monitor website changes automatically, filter noise, and get visual proof for every meaningful update.',
siteName: 'SiteChangeMonitor',
},
twitter: {
card: 'summary_large_image',
title: 'Website Change Monitor | SiteChangeMonitor',
description:
'Track page changes and competitor price updates with fewer false alerts and instant notifications.',
},
robots: {
index: true,
follow: true,
},
}
import { Toaster } from 'sonner'
@ -27,6 +71,126 @@ import { Toaster } from 'sonner'
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
import { CookieBanner } from '@/components/compliance/CookieBanner'
const softwareApplicationJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'SiteChangeMonitor',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web, Windows, macOS, Linux, iOS, Android',
url: siteUrl,
description:
'Website change monitor that detects meaningful updates, filters noise with AI, and sends instant alerts via email, Slack, and webhooks.',
offers: [
{
'@type': 'Offer',
name: 'Forever Free',
price: '0',
priceCurrency: 'USD',
description: 'Free plan — 5 monitors, 1-hour checks',
},
{
'@type': 'Offer',
name: 'Pro',
price: '19',
priceCurrency: 'USD',
description: 'Pro plan — 50 monitors, 5-minute checks, keyword alerts',
priceValidUntil: '2027-12-31',
},
{
'@type': 'Offer',
name: 'Business',
price: '99',
priceCurrency: 'USD',
description: 'Business plan — 200 monitors, 1-minute checks, team features',
priceValidUntil: '2027-12-31',
},
],
featureList: [
'AI-powered noise filtering for timestamps, cookie banners, and ads',
'Visual diff alerts with screenshot proof',
'Competitor price tracking',
'Keyword-based alerts (appear/disappear triggers)',
'Slack, email, and webhook notifications',
'One-click tracker setup — no CSS selectors needed',
'SEO SERP change monitoring',
'Compliance and policy change tracking',
],
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'What is the most accurate site change monitor for 2026?',
acceptedAnswer: {
'@type': 'Answer',
text: 'SiteChangeMonitor.com is the next-generation site change monitor designed to filter out the noise. Unlike older tools, we use AI to ignore localized ads, cookie banners, and footer dates, alerting you only when it matters. Join the waitlist to access the first "Zero-Noise" tracking engine.',
},
},
{
'@type': 'Question',
name: 'Can I use this for competitor price monitoring on Shopify or Amazon?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes, this is our specialty. Our platform offers dedicated competitor price monitoring trackers that lock onto price tags and inventory status. We automatically filter out false positives.',
},
},
{
'@type': 'Question',
name: 'How do I monitor a website for changes without coding?',
acceptedAnswer: {
'@type': 'Answer',
text: "With SiteChangeMonitor.com, you don't need CSS selectors. Simply paste the URL. Our One-Click Trackers automatically detect page type and configure the best settings for you.",
},
},
{
'@type': 'Question',
name: 'Why should I join the waitlist instead of using Visualping or Distill?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Current tools require hours of manual configuration to stop false alarms. By joining the waitlist, you lock in early access to the only tool that solves the "noise" problem with AI. Plus, waitlist members receive a permanent discount on the Pro plan.',
},
},
{
'@type': 'Question',
name: 'Is there a free version available?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. We will launch with a "Forever Free" plan for casual users. Joining the waitlist grants priority access to premium high-frequency monitoring features.',
},
},
],
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: 'Features', item: `${siteUrl}/features` },
{ '@type': 'ListItem', position: 3, name: 'Use Cases', item: `${siteUrl}/use-cases` },
{ '@type': 'ListItem', position: 4, name: 'Blog', item: `${siteUrl}/blog` },
],
}
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'SiteChangeMonitor',
url: siteUrl,
logo: `${siteUrl}/logo.png`,
sameAs: [],
}
const webSiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'SiteChangeMonitor',
url: siteUrl,
}
export default function RootLayout({
children,
}: {
@ -35,6 +199,26 @@ export default function RootLayout({
return (
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
<body className={interTight.className}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(webSiteJsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
/>
<PostHogProvider>
<Providers>{children}</Providers>
<CookieBanner />

View File

@ -44,7 +44,7 @@ export default function LoginPage() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
alt="SiteChangeMonitor Logo"
fill
className="object-contain"
priority
@ -52,7 +52,7 @@ export default function LoginPage() {
</div>
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Sign in to your Alertify account
Sign in to your SiteChangeMonitor account
</CardDescription>
</CardHeader>
@ -105,7 +105,7 @@ export default function LoginPage() {
<CardFooter className="justify-center border-t pt-6">
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
{"Don't have an account?"}{' '}
<Link
href="/register"
className="font-medium text-primary hover:underline"

View File

@ -301,7 +301,7 @@ export default function MonitorsPage() {
}
}
const monitors = data || []
const monitors = useMemo(() => data ?? [], [data])
const filteredMonitors = useMemo(() => {
if (filterStatus === 'all') return monitors
return monitors.filter((m: any) => m.status === filterStatus)
@ -589,7 +589,7 @@ export default function MonitorsPage() {
) : (
<>
{newMonitor.keywordRules.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click "Add Keyword" to create one.</p>
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click &quot;Add Keyword&quot; to create one.</p>
) : (
<div className="space-y-2">
{newMonitor.keywordRules.map((rule, index) => (
@ -896,7 +896,7 @@ export default function MonitorsPage() {
{/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
<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}"
&quot;{monitor.recentSnapshots[0].summary}&quot;
</p>
)}

View File

@ -0,0 +1,139 @@
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'SiteChangeMonitor - Website Change Detection'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'sans-serif',
position: 'relative',
}}
>
{/* Background Gradients/Glows */}
<div
style={{
position: 'absolute',
top: '-20%',
left: '-10%',
width: '600px',
height: '600px',
background: 'radial-gradient(circle, rgba(56, 189, 248, 0.15) 0%, rgba(15, 23, 42, 0) 70%)',
filter: 'blur(40px)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '-20%',
right: '-10%',
width: '700px',
height: '700px',
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.1) 0%, rgba(15, 23, 42, 0) 70%)',
filter: 'blur(60px)',
}}
/>
{/* Content Container */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
padding: '40px',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '24px',
background: 'rgba(30, 41, 59, 0.4)',
boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.3)',
}}
>
{/* Logo Section */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
marginBottom: '32px',
}}
>
<div
style={{
width: '72px',
height: '72px',
borderRadius: '18px',
background: 'linear-gradient(135deg, #06b6d4, #3b82f6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '36px',
color: 'white',
fontWeight: 800,
boxShadow: '0 4px 12px rgba(6, 182, 212, 0.4)',
}}
>
S
</div>
<span style={{ fontSize: '56px', fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>
SiteChangeMonitor
</span>
</div>
{/* Tagline */}
<p
style={{
fontSize: '32px',
fontWeight: 500,
color: '#cbd5e1',
maxWidth: '800px',
textAlign: 'center',
lineHeight: 1.4,
margin: '0 0 48px 0',
}}
>
Less noise. More signal.<br />
<span style={{ color: '#38bdf8' }}>Smart website change monitoring.</span>
</p>
{/* Features / Badges */}
<div
style={{
display: 'flex',
gap: '24px',
}}
>
{['Noise Filtering', 'Visual Diffs', 'Instant Alerts'].map((feature) => (
<div
key={feature}
style={{
padding: '10px 24px',
background: 'rgba(56, 189, 248, 0.1)',
border: '1px solid rgba(56, 189, 248, 0.2)',
borderRadius: '100px',
color: '#7dd3fc',
fontSize: '20px',
fontWeight: 600,
}}
>
{feature}
</div>
))}
</div>
</div>
</div>
),
{ ...size }
)
}

View File

@ -9,7 +9,9 @@ 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'
import { MagneticButton } from '@/components/landing/MagneticElements'
import { Check, ChevronDown, Menu } from 'lucide-react'
import { BackgroundGradient, FloatingElements, InteractiveGrid, GlowEffect } from '@/components/landing/BackgroundEffects'
// Dynamic imports for performance optimization (lazy loading)
const UseCaseShowcase = dynamic(
@ -56,54 +58,76 @@ export default function Home() {
const faqs = [
{
question: 'What is website monitoring?',
answer: 'Website monitoring is the process of testing and verifying that end-users can interact with a website or web application as expected. It continuously checks your website for changes, downtime, or performance issues.'
question: 'What is the most accurate site change monitor for 2026?',
answer: 'SiteChangeMonitor.com is the next-generation site change monitor designed to filter out the noise. Unlike older tools, we use AI to ignore localized ads, cookie banners, and footer dates, alerting you only when it matters. Join the waitlist to access the first "Zero-Noise" tracking engine.'
},
{
question: 'How fast are the alerts?',
answer: 'Our alerts are sent within seconds of detecting a change. You can configure notifications via email, webhook, Slack, or other integrations.'
question: 'Can I use this for competitor price monitoring on Shopify or Amazon?',
answer: 'Yes, this is our specialty. Our platform offers dedicated competitor price monitoring trackers that lock onto price tags and inventory status. We automatically filter out false positives.'
},
{
question: 'Can I monitor SSL certificates?',
answer: 'Yes! We automatically monitor SSL certificate expiration and will alert you before your certificate expires.'
question: 'How do I monitor a website for changes without coding?',
answer: "With SiteChangeMonitor.com, you don't need CSS selectors. Simply paste the URL. Our One-Click Trackers automatically detect page type and configure the best settings for you."
},
{
question: 'Do you offer a free trial?',
answer: 'Yes, we offer a free Starter plan that includes 3 monitors with hourly checks. No credit card required.'
question: 'Why should I join the waitlist instead of using Visualping or Distill?',
answer: 'Current tools require hours of manual configuration to stop false alarms. By joining the waitlist, you lock in early access to the only tool that solves the "noise" problem with AI. Plus, waitlist members receive a permanent discount on the Pro plan.'
},
{
question: 'Is there a free version available?',
answer: 'Yes. We will launch with a "Forever Free" plan for casual users. Joining the waitlist grants priority access to premium high-frequency monitoring features.'
}
]
return (
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/20 selection:text-primary">
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/20 selection:text-primary relative overflow-hidden">
{/* Background Effects */}
<BackgroundGradient />
<InteractiveGrid />
<FloatingElements />
<GlowEffect />
{/* Header */}
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<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="relative h-8 w-8 transition-transform group-hover:scale-110">
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
<Image
src="/logo.png"
alt="SiteChangeMonitor Logo"
fill
className="object-contain"
priority
fetchPriority="high"
sizes="32px"
/>
</div>
<span className="text-lg font-bold tracking-tight text-foreground">Alertify</span>
<span className="text-lg font-bold tracking-tight text-foreground">SiteChangeMonitor</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>
<Link href="#use-cases" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Use Cases</Link>
<Link href="/features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
<Link href="/use-cases" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Use Cases</Link>
</nav>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<MagneticButton strength={0.4}>
<Button
size="sm"
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20"
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform active:scale-95 shadow-md shadow-primary/20"
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
aria-label="Join the waitlist"
>
Join Waitlist
</Button>
</MagneticButton>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 text-muted-foreground hover:text-foreground"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle mobile menu"
>
<Menu className="h-6 w-6" />
</button>
@ -120,8 +144,8 @@ export default function Home() {
className="md:hidden border-t border-border bg-background px-6 py-4 shadow-lg overflow-hidden"
>
<div className="flex flex-col gap-4">
<Link href="#features" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features</Link>
<Link href="#use-cases" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Use Cases</Link>
<Link href="/features" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features</Link>
<Link href="/use-cases" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Use Cases</Link>
<button
onClick={() => {
setMobileMenuOpen(false)
@ -147,6 +171,8 @@ export default function Home() {
{/* Hero Section */}
<HeroSection />
{/* Continuous Gradient Wrapper for Content */}
<div className="gradient-ivory">
{/* Live SERP Preview Tool */}
<LiveSerpPreview />
@ -160,7 +186,7 @@ export default function Home() {
<Differentiators />
{/* FAQ Section */}
< section id="faq" className="border-t border-border/40 py-24 bg-background" >
<section id="faq" className="border-t border-border/40 py-24">
<div className="mx-auto max-w-3xl px-6">
<h2 className="mb-12 text-center text-3xl font-bold sm:text-4xl text-foreground">
Frequently Asked Questions
@ -182,15 +208,18 @@ export default function Home() {
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
/>
</button>
<AnimatePresence>
<AnimatePresence initial={false}>
{openFaq === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-border px-6 pb-6 pt-4 text-muted-foreground bg-secondary/5"
transition={{ duration: 0.3, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="border-t border-border px-6 pb-6 pt-4 text-muted-foreground bg-secondary/5">
{faq.answer}
</div>
</motion.div>
)}
</AnimatePresence>
@ -199,6 +228,7 @@ export default function Home() {
</div>
</div>
</section >
</div>
{/* Final CTA */}
<FinalCTA />

View File

@ -1,58 +1,210 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { ArrowLeft, Shield, Lock, Eye, Server, CreditCard, Mail } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export const metadata: Metadata = {
title: 'Privacy Policy',
description:
'Learn how SiteChangeMonitor collects, uses, and protects your personal data. Detailed information on GDPR compliance, data retention, and third-party services.',
alternates: { canonical: '/privacy' },
openGraph: {
title: 'Privacy Policy — SiteChangeMonitor',
description: 'Transparency is our policy. Learn how we handle your data.',
url: '/privacy',
},
}
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">
<div className="flex-1 py-16 md:py-24 px-6">
<div className="mx-auto max-w-4xl space-y-12">
{/* Header */}
<div className="space-y-6 text-center md:text-left">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors mb-4">
<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>
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground tracking-tight">Privacy Policy</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
We believe in transparency. Here's exactly how we handle your data, where it lives, and your rights under GDPR.
</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-secondary/30 w-fit px-3 py-1 rounded-full border border-border/50">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
Last updated: {new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</div>
</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>
{/* Main Content */}
<div className="grid md:grid-cols-[1fr_300px] gap-12">
<section className="space-y-12 prose prose-neutral dark:prose-invert max-w-none">
<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>
{/* 1. Introduction */}
<div className="space-y-4">
<div className="flex items-center gap-3 text-primary">
<Shield className="h-6 w-6" />
<h2 className="text-2xl font-semibold m-0">1. Introduction</h2>
</div>
<p className="text-muted-foreground leading-relaxed">
Welcome to SiteChangeMonitor ("we," "us," or "our"). We are committed to protecting your personal information and your right to privacy. This policy explains how we process your data when you use our website-monitoring services. By using our service, you agree to the collection and use of information in accordance with this policy.
</p>
</div>
{/* 2. Data We Collect */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">2. Data We Collect</h3>
<p className="text-muted-foreground">We collect the minimum amount of data necessary to provide our services:</p>
<ul className="grid gap-4 sm:grid-cols-2 list-none pl-0 my-4">
<li className="bg-secondary/10 p-4 rounded-lg border border-border/50">
<strong className="block text-foreground mb-1">Account Data</strong>
<span className="text-sm text-muted-foreground">Email address, encrypted password, and billing details (handled by our payment processor).</span>
</li>
<li className="bg-secondary/10 p-4 rounded-lg border border-border/50">
<strong className="block text-foreground mb-1">Monitoring Data</strong>
<span className="text-sm text-muted-foreground">URLs you track, snapshots of those pages, and change history.</span>
</li>
<li className="bg-secondary/10 p-4 rounded-lg border border-border/50">
<strong className="block text-foreground mb-1">Usage Data</strong>
<span className="text-sm text-muted-foreground">IP address, browser type, device info, and interaction logs via PostHog.</span>
</li>
</ul>
</div>
{/* 3. Infrastructure & Hosting */}
<div className="space-y-4">
<div className="flex items-center gap-3 text-primary">
<Server className="h-6 w-6" />
<h2 className="text-2xl font-semibold m-0">3. Infrastructure & Hosting</h2>
</div>
<p className="text-muted-foreground leading-relaxed">
We prioritize data sovereignty and security. Our core infrastructure is hosted on dedicated servers in <strong>Texas, USA</strong>. We maintain strict security protocols to protected your data, regardless of where you are located.
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li><strong>Core Data:</strong> Stored on our secure, private servers in the United States. We do not rely on public cloud buckets for sensitive monitoring data.</li>
<li><strong>Snapshots:</strong> Website screenshots are stored locally on our infrastructure.</li>
<li><strong>Backups:</strong> Encrypted backups are maintained to prevent data loss.</li>
</ul>
</div>
{/* 4. Third-Party Services */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">4. Third-Party Subprocessors</h3>
<p className="text-muted-foreground">We use trusted third-party services for specific functions. These partners adhering to strict data protection standards (GDPR/SCCs):</p>
<div className="grid gap-4">
<div className="flex gap-4 items-start p-4 rounded-lg border border-border/50 bg-background/50">
<CreditCard className="h-5 w-5 text-muted-foreground mt-1" />
<div>
<strong className="text-foreground">Stripe / LemonSqueezy</strong>
<p className="text-sm text-muted-foreground">Payment processing. We never see or store your full credit card number.</p>
</div>
</div>
<div className="flex gap-4 items-start p-4 rounded-lg border border-border/50 bg-background/50">
<Eye className="h-5 w-5 text-muted-foreground mt-1" />
<div>
<strong className="text-foreground">PostHog</strong>
<p className="text-sm text-muted-foreground">Product analytics to help us improve the user experience. IP addresses are anonymized.</p>
</div>
</div>
<div className="flex gap-4 items-start p-4 rounded-lg border border-border/50 bg-background/50">
<Mail className="h-5 w-5 text-muted-foreground mt-1" />
<div>
<strong className="text-foreground">Transactional Email Provider</strong>
<p className="text-sm text-muted-foreground">To send you change alerts and password resets.</p>
</div>
</div>
</div>
</div>
{/* 5. Your Global Privacy Rights */}
<div className="space-y-4">
<div className="flex items-center gap-3 text-primary">
<Lock className="h-6 w-6" />
<h2 className="text-2xl font-semibold m-0">5. Your Global Privacy Rights</h2>
</div>
<p className="text-muted-foreground">
We believe privacy is a fundamental right. Regardless of where you live, we extend high-standard privacy protections to all our users.
</p>
<div className="mt-4 space-y-4">
<h4 className="font-semibold text-foreground">For Users in the EEA & UK (GDPR)</h4>
<ul className="space-y-2 text-muted-foreground pl-4 border-l-2 border-primary/20">
<li><strong>Right to Access:</strong> Request a copy of your personal data.</li>
<li><strong>Right to Rectification:</strong> Request correction of inaccurate data.</li>
<li><strong>Right to Erasure ("Right to be Forgotten"):</strong> Request deletion of all your data.</li>
<li><strong>Right to Portability:</strong> Receive your data in a structured format.</li>
</ul>
</div>
<div className="mt-6 space-y-4">
<h4 className="font-semibold text-foreground">For Users in the US (CCPA/CPRA & State Laws)</h4>
<ul className="space-y-2 text-muted-foreground pl-4 border-l-2 border-primary/20">
<li><strong>No Sale of Data:</strong> We do not sell your personal information to third parties.</li>
<li><strong>Right to Know:</strong> You may request details on the categories of personal data we collect.</li>
<li><strong>Right to Delete:</strong> You may request the deletion of your personal information, subject to certain legal exceptions.</li>
<li><strong>Non-Discrimination:</strong> We will not treat you differently for exercising your privacy rights.</li>
</ul>
</div>
<div className="mt-6 p-4 bg-secondary/10 border border-border rounded-lg text-sm text-muted-foreground">
To exercise any of these rights, contact us at <a href="mailto:privacy@sitechangemonitor.com" className="text-primary hover:underline font-medium">privacy@sitechangemonitor.com</a>. We respond to all valid requests within 30 days.
</div>
</div>
{/* 6. Cookies */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">6. Cookies & Local Storage</h3>
<p className="text-muted-foreground">
We use cookies strictly for essential functions (authentication, security) and analytical purposes (to understand general usage patterns via PostHog). You can control cookies through your browser settings, though disabling them may affect your ability to log in.
</p>
</div>
{/* 7. Contact */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-foreground">7. Contact Us</h3>
<p className="text-muted-foreground">
Have questions about privacy? Reach out to our Data Protection Officer (DPO) directly.
</p>
<div className="p-6 bg-primary/5 rounded-xl border border-primary/20 flex flex-col sm:flex-row items-center gap-6">
<div className="p-3 bg-background rounded-full border border-border shadow-sm">
<Mail className="h-6 w-6 text-primary" />
</div>
<div className="text-center sm:text-left">
<p className="font-semibold text-foreground">SiteChangeMonitor Privacy Team</p>
<a href="mailto:privacy@sitechangemonitor.com" className="text-primary hover:underline text-lg font-medium">privacy@sitechangemonitor.com</a>
</div>
</div>
</div>
</section>
{/* Sidebar / TOC */}
<aside className="hidden md:block">
<div className="sticky top-24 space-y-8">
<div className="p-6 rounded-xl border border-border bg-card/50 backdrop-blur-sm">
<h4 className="font-semibold mb-4 text-foreground">Table of Contents</h4>
<nav className="space-y-2 text-sm">
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">1. Introduction</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">2. Data Collection</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">3. Infrastructure</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">4. Subprocessors</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">5. Global Privacy Rights</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">6. Cookies</a>
<a href="#" className="block text-muted-foreground hover:text-primary transition-colors">7. Contact</a>
</nav>
</div>
<div className="p-6 rounded-xl border border-border bg-gradient-to-br from-primary/5 to-transparent">
<h4 className="font-semibold mb-2 text-foreground">Need help?</h4>
<p className="text-sm text-muted-foreground mb-4">
Our support team is available to answer specific questions about your data.
</p>
<Link href="mailto:support@sitechangemonitor.com" className="text-sm font-medium text-primary hover:underline">
Contact Support &rarr;
</Link>
</div>
</div>
</aside>
</div>
</div>
</div>
<Footer />

View File

@ -63,7 +63,7 @@ export default function RegisterPage() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
alt="SiteChangeMonitor Logo"
fill
className="object-contain"
priority

34
frontend/app/sitemap.ts Normal file
View File

@ -0,0 +1,34 @@
import { MetadataRoute } from 'next'
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://sitechangemonitor.com'
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
const staticPages = [
{ url: `${BASE_URL}/`, changeFrequency: 'weekly' as const, priority: 1.0 },
{ url: `${BASE_URL}/blog`, changeFrequency: 'weekly' as const, priority: 0.7 },
{ url: `${BASE_URL}/privacy`, changeFrequency: 'yearly' as const, priority: 0.3 },
{ url: `${BASE_URL}/features`, changeFrequency: 'monthly' as const, priority: 0.9 },
{ url: `${BASE_URL}/use-cases`, changeFrequency: 'monthly' as const, priority: 0.9 },
]
const featureSlugs = ['noise-filtering', 'visual-diff', 'keyword-monitoring', 'seo-ranking', 'multi-channel-alerts']
const featurePages = featureSlugs.map((slug) => ({
url: `${BASE_URL}/features/${slug}`,
changeFrequency: 'monthly' as const,
priority: 0.8,
}))
const useCaseSlugs = ['ecommerce-price-monitoring', 'seo-serp-tracking', 'compliance-policy-monitoring', 'competitor-intelligence']
const useCasePages = useCaseSlugs.map((slug) => ({
url: `${BASE_URL}/use-cases/${slug}`,
changeFrequency: 'monthly' as const,
priority: 0.8,
}))
return [...staticPages, ...featurePages, ...useCasePages].map((page) => ({
...page,
lastModified: now,
}))
}

View File

@ -0,0 +1,226 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
import { notFound } from 'next/navigation'
const useCases: Record<string, {
title: string
metaDescription: string
intro: string
benefits: string[]
howItWorks: { step: string; description: string }[]
whoIsItFor: string[]
}> = {
'ecommerce-price-monitoring': {
title: 'E-Commerce Price Monitoring',
metaDescription: 'Monitor competitor prices on Shopify, Amazon, and any e-commerce site. SiteChangeMonitor tracks price changes and inventory status with AI-powered noise filtering.',
intro: 'SiteChangeMonitor tracks competitor product prices and inventory status across Shopify, Amazon, WooCommerce, and any e-commerce site — filtering out false alerts from rotating ads and dynamic page elements.',
benefits: [
'Lock onto specific price elements automatically — no CSS selectors needed',
'Filter out false positives from ads, banners, and session-specific content',
'Get instant email alerts (or Slack/webhook on Pro+) when a competitor changes pricing',
'Track inventory status changes (in stock → sold out)',
'Historical price snapshots with visual proof',
],
howItWorks: [
{ step: 'Paste the product URL', description: 'Our system auto-detects the page type and locks onto the price element.' },
{ step: 'Set your alert preferences', description: 'Choose email (all plans) or Slack/webhook (Pro+). Set thresholds for price changes.' },
{ step: 'Get notified on real changes', description: 'AI filters out noise. You only hear about actual price or inventory changes.' },
],
whoIsItFor: [
'E-commerce managers tracking competitor pricing',
'Marketplace sellers monitoring Buy Box prices',
'Procurement teams watching supplier pricing',
'Deal hunters tracking product price drops',
],
},
'seo-serp-tracking': {
title: 'SEO & SERP Change Tracking',
metaDescription: 'Monitor SERP changes, featured snippet updates, and competitor ranking movements. SiteChangeMonitor alerts SEO teams to meaningful search result changes.',
intro: 'SiteChangeMonitor helps SEO teams track changes to search engine results pages, including ranking shifts, featured snippet appearances, and competitor movements — without noise from personalized or localized results.',
benefits: [
'Track SERP changes for your target keywords',
'Monitor featured snippet ownership changes',
'Detect when competitors appear or disappear from page 1',
'Filter out localized and personalized result variations',
'Get visual diff proof of every SERP change',
],
howItWorks: [
{ step: 'Enter the Google search URL for your keyword', description: 'We capture the SERP as it appears to a clean, non-personalized browser session.' },
{ step: 'Configure keyword triggers', description: 'Set alerts for when your brand or competitor names appear or disappear.' },
{ step: 'Review changes with visual diffs', description: 'See exactly what changed with side-by-side screenshot comparisons.' },
],
whoIsItFor: [
'SEO managers tracking keyword rankings',
'Content teams monitoring featured snippets',
'Agencies reporting SERP changes to clients',
'Growth teams tracking competitive search landscape',
],
},
'compliance-policy-monitoring': {
title: 'Compliance & Policy Change Monitoring',
metaDescription: 'Track changes to terms of service, privacy policies, and regulatory pages. SiteChangeMonitor provides audit-proof snapshots for compliance teams.',
intro: 'SiteChangeMonitor provides compliance and legal teams with automated tracking of terms of service, privacy policies, and regulatory pages — with audit-proof snapshots and instant change alerts.',
benefits: [
'Automatically monitor ToS, privacy policies, and regulatory pages',
'Audit-proof timestamped snapshots of every version',
'AI filtering removes irrelevant changes (dates, formatting, ads)',
'Instant alerts when material policy changes occur',
'Full version history with diff comparison',
],
howItWorks: [
{ step: 'Add the policy or regulatory page URL', description: 'SiteChangeMonitor begins tracking the page content immediately.' },
{ step: 'AI filters noise automatically', description: 'Copyright year changes, formatting tweaks, and boilerplate updates are ignored.' },
{ step: 'Get alerted on material changes', description: 'Only substantive policy changes trigger notifications to your team.' },
],
whoIsItFor: [
'Legal teams monitoring vendor terms of service',
'Compliance officers tracking regulatory changes',
'Privacy teams monitoring third-party data policies',
'Risk managers tracking contractual obligations',
],
},
'competitor-intelligence': {
title: 'Competitor Intelligence Monitoring',
metaDescription: 'Monitor competitor websites for product launches, pricing changes, hiring signals, and strategic shifts. SiteChangeMonitor automates competitive intelligence.',
intro: 'SiteChangeMonitor automates competitive intelligence by monitoring competitor websites for product launches, pricing changes, team growth signals, and strategic messaging shifts — delivering only meaningful changes to your team.',
benefits: [
'Monitor competitor homepages, pricing pages, and product pages',
'Detect new product launches and feature announcements',
'Track hiring page changes as growth signals',
'Keyword alerts for strategic terms (e.g., "enterprise", "AI")',
'Weekly digest emails for competitive intelligence summaries (coming soon)',
],
howItWorks: [
{ step: 'Add competitor page URLs', description: 'Monitor pricing pages, about pages, careers pages, and product pages.' },
{ step: 'Set keyword triggers', description: 'Get alerted when competitors mention specific terms or remove them.' },
{ step: 'Review changes in context', description: 'Visual diffs show exactly what changed, so your team can act fast.' },
],
whoIsItFor: [
'Product managers tracking competitor features',
'Marketing teams monitoring competitor messaging',
'Sales teams tracking competitor pricing changes',
'Strategy teams monitoring industry shifts',
],
},
}
export function generateStaticParams() {
return Object.keys(useCases).map((slug) => ({ slug }))
}
export function generateMetadata({ params }: { params: { slug: string } }): Metadata {
const data = useCases[params.slug]
if (!data) return {}
return {
title: data.title,
description: data.metaDescription,
alternates: { canonical: `/use-cases/${params.slug}` },
openGraph: { title: data.title, description: data.metaDescription, url: `/use-cases/${params.slug}` },
}
}
export default function UseCasePage({ params }: { params: { slug: string } }) {
const data = useCases[params.slug]
if (!data) notFound()
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: `How to use SiteChangeMonitor for ${data.title}`,
step: data.howItWorks.map((s, i) => ({
'@type': 'HowToStep',
position: i + 1,
name: s.step,
text: s.description,
})),
}
return (
<div className="min-h-screen bg-background flex flex-col">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(howToJsonLd) }}
/>
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-4xl space-y-12">
<div className="space-y-4">
<Link href="/use-cases" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
All Use Cases
</Link>
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground">
{data.title}
</h1>
<p className="text-xl text-muted-foreground max-w-3xl">{data.intro}</p>
</div>
{/* Benefits */}
<section>
<h2 className="text-2xl font-bold text-foreground mb-4">Key Benefits</h2>
<ul className="space-y-3 text-muted-foreground">
{data.benefits.map((b, i) => (
<li key={i} className="flex items-start gap-3">
<span className="mt-1 h-2 w-2 rounded-full bg-primary shrink-0" />
{b}
</li>
))}
</ul>
</section>
{/* How It Works */}
<section className="rounded-2xl border border-border bg-card p-8">
<h2 className="text-2xl font-bold text-foreground mb-6">How It Works</h2>
<dl className="space-y-6">
{data.howItWorks.map((step, i) => (
<div key={i} className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
{i + 1}
</div>
<div>
<dt className="font-medium text-foreground">{step.step}</dt>
<dd className="mt-1 text-muted-foreground">{step.description}</dd>
</div>
</div>
))}
</dl>
</section>
{/* Who Is It For */}
<section>
<h2 className="text-2xl font-bold text-foreground mb-4">Who Is This For?</h2>
<ul className="grid md:grid-cols-2 gap-3 text-muted-foreground">
{data.whoIsItFor.map((who, i) => (
<li key={i} className="flex items-start gap-3">
<span className="mt-1 h-2 w-2 rounded-full bg-primary shrink-0" />
{who}
</li>
))}
</ul>
</section>
{/* CTA */}
<section className="text-center py-12">
<h2 className="text-2xl font-bold text-foreground mb-4">Start monitoring today</h2>
<p className="text-muted-foreground mb-6">Join the waitlist for early access and a permanent Pro discount.</p>
<Link
href="/"
className="inline-flex items-center rounded-full bg-primary px-8 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Join the Waitlist
</Link>
</section>
{/* Internal Links */}
<nav className="flex flex-wrap gap-3 text-sm">
<Link href="/features" className="text-primary hover:underline">Explore Features</Link>
<span className="text-muted-foreground"></span>
<Link href="/use-cases" className="text-primary hover:underline">More Use Cases</Link>
</nav>
</div>
</div>
<Footer />
</div>
)
}

View File

@ -0,0 +1,92 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export const metadata: Metadata = {
title: 'Use Cases — Website Change Monitoring for Every Team',
description:
'Discover how SiteChangeMonitor helps e-commerce, SEO, compliance, and competitive intelligence teams track website changes automatically.',
alternates: { canonical: '/use-cases' },
openGraph: {
title: 'Use Cases — SiteChangeMonitor',
description: 'Website change monitoring for e-commerce, SEO, compliance, and CI teams.',
url: '/use-cases',
},
}
const useCases = [
{
slug: 'ecommerce-price-monitoring',
title: 'E-Commerce Price Monitoring',
description: 'Track competitor prices on Shopify, Amazon, and any e-commerce site. Get alerted when prices drop or inventory changes.',
},
{
slug: 'seo-serp-tracking',
title: 'SEO & SERP Tracking',
description: 'Monitor search engine results pages for ranking changes, featured snippet updates, and competitor movements.',
},
{
slug: 'compliance-policy-monitoring',
title: 'Compliance & Policy Monitoring',
description: 'Track changes to terms of service, privacy policies, and regulatory pages. Maintain audit-proof snapshots.',
},
{
slug: 'competitor-intelligence',
title: 'Competitor Intelligence',
description: 'Monitor competitor websites for product launches, pricing changes, job postings, and strategic shifts.',
},
]
export default function UseCasesPage() {
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">
Use Cases
</h1>
<p className="text-xl text-muted-foreground max-w-3xl">
SiteChangeMonitor helps teams across industries track the web pages that matter most with zero noise and instant alerts.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{useCases.map((uc) => (
<Link
key={uc.slug}
href={`/use-cases/${uc.slug}`}
className="group rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors"
>
<h2 className="text-xl font-bold text-foreground group-hover:text-primary transition-colors">
{uc.title}
</h2>
<p className="mt-2 text-muted-foreground">{uc.description}</p>
<span className="mt-4 inline-block text-sm font-medium text-primary">
Learn more
</span>
</Link>
))}
</div>
<section className="text-center py-12">
<h2 className="text-2xl font-bold text-foreground mb-4">Don&apos;t see your use case?</h2>
<p className="text-muted-foreground mb-6">Join the waitlist and tell us what you need. We&apos;re building for you.</p>
<Link
href="/"
className="inline-flex items-center rounded-full bg-primary px-8 py-3 font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Join the Waitlist
</Link>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@ -6,14 +6,15 @@ 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', {
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY
if (typeof window !== 'undefined' && !posthog.__loaded && posthogKey) {
posthog.init(posthogKey, {
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_pageview: false,
capture_pageleave: true,
persistence: 'localStorage+cookie',
opt_out_capturing_by_default: true,
debug: true,
debug: process.env.NODE_ENV === 'development',
})
}
}, [])

View File

@ -10,20 +10,20 @@ 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) {
const cookieConsent = localStorage.getItem('cookie_consent')
if (!cookieConsent) {
setShow(true)
}
}, [])
const handleAccept = () => {
localStorage.setItem('cookie_consent', 'accepted')
posthog.opt_in_capturing()
setShow(false)
}
const handleDecline = () => {
localStorage.setItem('cookie_consent', 'declined')
posthog.opt_out_capturing()
setShow(false)
}
@ -45,7 +45,7 @@ export function CookieBanner() {
<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.
We use cookies to enhance your browsing experience and analyze our traffic. By clicking &quot;Accept&quot;, 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">

View File

@ -0,0 +1,112 @@
'use client'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
export function BackgroundGradient() {
return (
<div className="fixed inset-0 -z-30 overflow-hidden pointer-events-none">
<div
className="absolute inset-x-0 -top-40 -z-30 transform-gpu overflow-hidden blur-3xl sm:-top-80"
aria-hidden="true"
>
<div
className="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[hsl(var(--primary))] to-[hsl(var(--teal))] opacity-20 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
style={{
clipPath:
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
}}
/>
</div>
<div
className="absolute inset-x-0 top-[calc(100%-13rem)] -z-30 transform-gpu overflow-hidden blur-3xl sm:top-[calc(100%-30rem)]"
aria-hidden="true"
>
<div
className="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[hsl(var(--burgundy))] to-[hsl(var(--primary))] opacity-20 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]"
style={{
clipPath:
'polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)',
}}
/>
</div>
</div>
)
}
export function FloatingElements() {
return (
<div className="fixed inset-0 -z-20 pointer-events-none overflow-hidden">
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-transparent blur-3xl"
animate={{
x: [Math.random() * 100 + '%', Math.random() * 100 + '%'],
y: [Math.random() * 100 + '%', Math.random() * 100 + '%'],
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 20 + Math.random() * 10,
repeat: Infinity,
ease: "linear",
}}
style={{
left: Math.random() * 100 + '%',
top: Math.random() * 100 + '%',
}}
/>
))}
</div>
)
}
export function InteractiveGrid() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY })
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
return (
<div className="fixed inset-0 -z-30 pointer-events-none">
<div
className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px]"
/>
<div
className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent"
/>
<motion.div
className="absolute inset-0 bg-[radial-gradient(600px_at_var(--x)_var(--y),hsl(var(--primary)/0.08),transparent_80%)]"
style={{
// @ts-ignore
'--x': mousePos.x + 'px',
'--y': mousePos.y + 'px',
}}
/>
</div>
)
}
export function GlowEffect() {
return (
<div className="fixed inset-0 -z-20 pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-[120px] mix-blend-screen" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-teal/10 rounded-full blur-[120px] mix-blend-screen" />
</div>
)
}
export function SectionDivider() {
return (
<div className="relative h-px w-full">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-primary/20 to-transparent blur-sm" />
</div>
)
}

View File

@ -1,11 +1,25 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Bell, ArrowDown } from 'lucide-react'
function resolveHsl(cssVar: string): string {
if (typeof window === 'undefined') return 'transparent'
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
return value ? `hsl(${value})` : 'transparent'
}
export function CompetitorDemoVisual() {
const [phase, setPhase] = useState(0)
const [colors, setColors] = useState({ burgundy: '#993350', border: '#27272a' })
useEffect(() => {
setColors({
burgundy: resolveHsl('--burgundy'),
border: resolveHsl('--border'),
})
}, [])
useEffect(() => {
const interval = setInterval(() => {
@ -15,7 +29,7 @@ export function CompetitorDemoVisual() {
}, [])
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--primary))]/5 rounded-xl p-4 overflow-hidden">
<div className="relative h-full bg-gradient-to-br from-background via-background to-[hsl(var(--primary))]/5 rounded-xl p-4 overflow-hidden">
{/* Browser Header */}
<div className="mb-3 flex items-center gap-2 px-2 py-1.5 rounded-md bg-secondary/50 border border-border">
<div className="flex gap-1">
@ -36,9 +50,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 ? 'hsl(var(--burgundy))' : '#27272a',
borderColor: phase === 1 ? colors.burgundy : colors.border,
boxShadow: phase === 1
? '0 0 20px hsl(var(--burgundy) / 0.2)'
? `0 0 20px ${colors.burgundy}33`
: '0 1px 3px rgba(0,0,0,0.5)'
}}
transition={{ duration: 0.5 }}
@ -67,7 +81,7 @@ export function CompetitorDemoVisual() {
className="text-3xl font-bold"
animate={{
textDecoration: phase === 1 ? 'line-through' : 'none',
color: phase === 1 ? 'hsl(var(--burgundy))' : '#f4f4f5'
color: phase === 1 ? colors.burgundy : '#f4f4f5'
}}
>
$99

View File

@ -6,14 +6,15 @@ import { Button } from '@/components/ui/button'
import {
Check, ArrowRight, Shield, Search, FileCheck, TrendingUp,
Target, Filter, Bell, Eye, Slack, Webhook, History,
Zap, Lock, ChevronRight, Star
Zap, Lock, ChevronRight, Star, Accessibility
} from 'lucide-react'
import { useState, useEffect } from 'react'
import { SEODemoVisual } from './SEODemoVisual'
import { CompetitorDemoVisual } from './CompetitorDemoVisual'
import { PolicyDemoVisual } from './PolicyDemoVisual'
import { WaitlistForm } from './WaitlistForm'
import { MagneticButton, SectionDivider } from './MagneticElements'
import { MagneticButton } from './MagneticElements'
import { BackgroundGradient, FloatingElements, InteractiveGrid, GlowEffect, SectionDivider } from './BackgroundEffects'
// Animation Variants
const fadeInUp: Variants = {
@ -30,21 +31,12 @@ const fadeInUp: Variants = {
})
}
const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] }
}
}
// ============================================
// 1. HERO SECTION - "Track competitor changes without the noise"
// 1. HERO SECTION
// ============================================
export function HeroSection() {
return (
<section id="hero" className="relative overflow-hidden pt-32 pb-24 lg:pt-40 lg:pb-32 bg-[hsl(var(--section-bg-1))]">
<section id="hero" className="relative overflow-hidden pt-32 pb-24 lg:pt-40 lg:pb-32 gradient-velvet">
{/* Background Elements */}
<div className="absolute inset-0 grain-texture" />
<div className="absolute right-0 top-20 -z-10 h-[600px] w-[600px] rounded-full bg-[hsl(var(--primary))] opacity-8 blur-[120px]" />
@ -65,7 +57,7 @@ export function HeroSection() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--teal))] opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-[hsl(var(--teal))]"></span>
</span>
For SEO & Growth Teams
Website Change Monitor for SEO & Growth Teams
</div>
</motion.div>
@ -75,8 +67,8 @@ export function HeroSection() {
custom={1}
className="text-5xl lg:text-7xl font-display font-bold leading-[1.08] tracking-tight text-foreground"
>
Track competitor changes{' '}
<span className="text-[hsl(var(--primary))]">without the noise.</span>
Monitor website changes &{' '}
<span className="text-[hsl(var(--primary))]">price drops automatically.</span>
</motion.h1>
{/* Subheadline */}
@ -85,7 +77,7 @@ export function HeroSection() {
custom={2}
className="text-xl lg:text-2xl text-muted-foreground font-body leading-relaxed max-w-2xl"
>
Less noise. More signal. Proof included.
Less noise. More signal. Visual proof included.
</motion.p>
{/* Feature Bullets */}
@ -156,7 +148,44 @@ export function HeroSection() {
)
}
// Noise → Signal Animation Component - Enhanced
// ============================================
// 1b. TRUST SECTION - "As seen on..."
// ============================================
function TrustSectionDeprecated() {
const logos = [
{ name: 'SEO Clarity', color: 'text-muted-foreground' },
{ name: 'Search Engine Journal', color: 'text-muted-foreground' },
{ name: 'Moz', color: 'text-muted-foreground' },
{ name: 'Ahrefs', color: 'text-muted-foreground' },
{ name: 'Semrush', color: 'text-muted-foreground' }
]
return (
<section className="py-12 border-y border-border/50 bg-secondary/10">
<div className="mx-auto max-w-7xl px-6">
<p className="text-center text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground/80 mb-8">
The Essential Toolkit for Industry Leaders
</p>
<div className="flex flex-wrap justify-center items-center gap-x-12 gap-y-8 opacity-40 grayscale hover:grayscale-0 transition-all duration-700">
{logos.map((logo, i) => (
<motion.div
key={i}
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.8 }}
className={`text-xl font-display font-black tracking-tighter ${logo.color}`}
>
{logo.name}
</motion.div>
))}
</div>
</div>
</section>
)
}
// Noise → Signal Animation Component
function NoiseToSignalVisual() {
const [phase, setPhase] = useState(0)
const [isPaused, setIsPaused] = useState(false)
@ -167,7 +196,6 @@ function NoiseToSignalVisual() {
const interval = setInterval(() => {
setPhase(p => {
const nextPhase = (p + 1) % 4
// Trigger particles when transitioning from phase 0 to 1
if (p === 0 && nextPhase === 1) {
triggerParticles()
}
@ -189,7 +217,7 @@ function NoiseToSignalVisual() {
return (
<motion.div
className="relative aspect-[4/3] rounded-3xl border-2 border-border bg-card/50 backdrop-blur-sm shadow-2xl overflow-hidden cursor-pointer group"
className="relative aspect-[4/3] min-h-[320px] rounded-3xl border-2 border-border bg-card/50 backdrop-blur-sm shadow-2xl overflow-hidden cursor-pointer group"
style={{ perspective: '1000px' }}
whileHover={{ rotateY: 2, rotateX: -2, scale: 1.02 }}
transition={{ duration: 0.3 }}
@ -249,7 +277,6 @@ function NoiseToSignalVisual() {
{/* Content Area */}
<div className="p-8 space-y-4 relative">
{/* Noise Counter */}
<motion.div
className="absolute top-4 left-4 px-3 py-1 rounded-full bg-background/80 backdrop-blur-sm border border-border text-xs font-mono font-semibold"
animate={{
@ -266,176 +293,80 @@ function NoiseToSignalVisual() {
opacity: phase === 0 ? 1 : 0,
scale: phase === 0 ? 1 : 0.98
}}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
transition={{ duration: 0.5 }}
className="space-y-3"
>
{/* Cookie Banner - with strikethrough */}
<motion.div
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
animate={{
x: phase >= 1 ? -10 : 0,
opacity: phase >= 1 ? 0.3 : 1
}}
transition={{ duration: 0.4 }}
>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden">
<span className="text-xs text-muted-foreground">🍪 Cookie Banner</span>
<span className="text-xs text-red-500 font-semibold">
NOISE
</span>
{/* Strikethrough animation */}
{phase >= 1 && (
<motion.div
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.3 }}
/>
)}
</motion.div>
{/* Enterprise Plan Card */}
<span className="text-xs text-red-500 font-semibold">NOISE</span>
</div>
<div className="p-4 rounded-lg bg-background border border-border">
<p className="text-sm font-semibold text-foreground mb-2">Enterprise Plan</p>
<p className="text-2xl font-bold text-[hsl(var(--burgundy))]">$99/mo</p>
</div>
{/* Timestamp - with strikethrough */}
<motion.div
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
animate={{
x: phase >= 1 ? -10 : 0,
opacity: phase >= 1 ? 0.3 : 1
}}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden">
<span className="text-xs text-muted-foreground"> Last updated: 10:23 AM</span>
<span className="text-xs text-red-500 font-semibold">
NOISE
</span>
{/* Strikethrough animation */}
{phase >= 1 && (
<motion.div
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</motion.div>
<span className="text-xs text-red-500 font-semibold">NOISE</span>
</div>
</motion.div>
{/* Phase 1-3: Filtered + Highlighted Signal */}
{/* Phase 1-3: Signal */}
{phase >= 1 && (
<motion.div
initial={{ opacity: 0, scale: 0.85, rotateX: -15 }}
animate={{
opacity: 1,
scale: 1,
rotateX: 0
}}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
scale: { type: 'spring', stiffness: 300, damping: 20 }
}}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 flex items-center justify-center p-8"
>
<motion.div
className="w-full p-6 rounded-2xl bg-white dark:bg-zinc-950 border-2 border-[hsl(var(--teal))] dark:border-zinc-800 shadow-2xl relative overflow-hidden"
animate={{
boxShadow: [
'0 20px 60px rgba(20, 184, 166, 0.1)',
'0 20px 80px rgba(20, 184, 166, 0.2)',
'0 20px 60px rgba(20, 184, 166, 0.1)'
]
}}
transition={{ duration: 2, repeat: Infinity }}
>
{/* Animated corner accent */}
<motion.div
className="absolute top-0 right-0 w-20 h-20 bg-[hsl(var(--teal))]/5 rounded-bl-full"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
<div className="relative z-10">
<div className="w-full p-6 rounded-2xl bg-white dark:bg-zinc-950 border-2 border-[hsl(var(--teal))] shadow-2xl relative">
<div className="flex items-center justify-between mb-2">
<motion.span
className="text-xs font-bold uppercase tracking-wider text-[hsl(var(--teal))] dark:text-[hsl(var(--teal))]"
animate={{ opacity: [1, 0.7, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
SIGNAL DETECTED
</motion.span>
<div className="flex items-center gap-1.5 text-xs font-medium text-[hsl(var(--teal))] dark:text-[hsl(var(--teal))]">
<Filter className="h-3 w-3" />
Filtered
<span className="text-xs font-bold uppercase tracking-wider text-[hsl(var(--teal))]"> SIGNAL DETECTED</span>
<div className="flex items-center gap-1.5 text-xs text-[hsl(var(--teal))]">
<Filter className="h-3 w-3" /> Filtered
</div>
</div>
<p className="text-sm font-semibold text-muted-foreground dark:text-zinc-400 mb-3">Enterprise Plan</p>
<p className="text-sm font-semibold text-muted-foreground mb-3">Enterprise Plan</p>
<div className="flex items-baseline gap-3">
<p className="text-3xl font-bold text-foreground dark:text-zinc-600/50">$99/mo</p>
<p className="text-3xl font-bold text-foreground">$99/mo</p>
{phase >= 2 && (
<motion.p
initial={{ opacity: 0, x: -10, scale: 0.9 }}
animate={{
opacity: phase >= 2 ? 1 : 0,
x: phase >= 2 ? 0 : -10,
scale: phase >= 2 ? 1 : 0.9
}}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="text-lg text-[hsl(var(--burgundy))] dark:text-red-500 font-bold flex items-center gap-1"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="text-3xl text-[hsl(var(--burgundy))] font-bold"
>
<span></span>
<motion.span
animate={{ scale: phase === 2 ? [1, 1.1, 1] : 1 }}
transition={{ duration: 0.5 }}
className="text-3xl"
>
$79/mo
</motion.span>
$79/mo
</motion.p>
)}
</div>
{/* Alert badge */}
{phase >= 3 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 dark:bg-red-500/10 dark:border-red-500/20"
className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))] dark:text-red-500" />
<span className="text-[10px] font-bold text-[hsl(var(--burgundy))] dark:text-red-500 uppercase tracking-wider">
Alert Sent
</span>
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
<span className="text-[10px] font-bold text-[hsl(var(--burgundy))] uppercase tracking-wider">Alert Sent</span>
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
{/* Phase Indicator */}
<div className="absolute bottom-4 right-4 flex gap-1.5">
{[0, 1, 2, 3].map(i => (
<motion.div
<div
key={i}
animate={{
width: phase === i ? 24 : 6,
backgroundColor: phase === i ? 'hsl(var(--teal))' : 'hsl(var(--border))'
}}
transition={{ duration: 0.3 }}
className="h-1.5 rounded-full"
className={`h-1.5 rounded-full transition-all duration-300 ${phase === i ? 'w-6 bg-[hsl(var(--teal))]' : 'w-1.5 bg-border'}`}
/>
))}
</div>
</div >
</motion.div >
</div>
</motion.div>
)
}
// ============================================
// 2. USE CASE SHOWCASE - SEO, Competitor, Policy
// 2. USE CASE SHOWCASE
// ============================================
export function UseCaseShowcase() {
const useCases = [
@ -445,7 +376,6 @@ export function UseCaseShowcase() {
problem: 'Your rankings drop before you know why.',
example: 'Track when competitors update meta descriptions or add new content sections that outrank you.',
color: 'teal',
gradient: 'from-[hsl(var(--teal))]/10 to-transparent',
demoComponent: <SEODemoVisual />
},
{
@ -454,7 +384,6 @@ export function UseCaseShowcase() {
problem: 'Competitor launches slip past your radar.',
example: 'Monitor pricing pages, product launches, and promotional campaigns in real-time.',
color: 'primary',
gradient: 'from-[hsl(var(--primary))]/10 to-transparent',
demoComponent: <CompetitorDemoVisual />
},
{
@ -463,83 +392,55 @@ export function UseCaseShowcase() {
problem: 'Regulatory updates appear without warning.',
example: 'Track policy changes, terms updates, and legal text modifications with audit-proof history.',
color: 'burgundy',
gradient: 'from-[hsl(var(--burgundy))]/10 to-transparent',
demoComponent: <PolicyDemoVisual />
}
]
return (
<section className="py-32 bg-[hsl(var(--section-bg-3))] relative overflow-hidden">
{/* Background Decor - Enhanced Grid */}
<section className="py-32 relative overflow-hidden">
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border))_1px,transparent_1px)] bg-[size:4rem_4rem] opacity-30 [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
<div className="mx-auto max-w-7xl px-6 relative z-10">
{/* Section Header */}
<div className="text-center mb-20">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
className="text-center mb-20"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="inline-flex items-center gap-2 rounded-full bg-secondary border border-border px-4 py-1.5 text-sm font-medium text-foreground mb-6"
>
<motion.div variants={fadeInUp} className="inline-flex items-center gap-2 rounded-full bg-secondary border border-border px-4 py-1.5 text-sm font-medium text-foreground mb-6">
<Eye className="h-4 w-4" />
Who This Is For
<Eye className="h-4 w-4" /> Who This Is For
</motion.div>
<motion.h2 variants={fadeInUp} custom={1} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
Built for teams who need results,{' '}
<span className="text-muted-foreground">not demos.</span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6"
>
Built for teams who need results, <span className="text-muted-foreground">not demos.</span>
</motion.h2>
</motion.div>
</div>
{/* Use Case Cards - Diagonal Cascade */}
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{useCases.map((useCase, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 40, rotateX: 10 }}
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.15, duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ y: -12, scale: 1.02, transition: { duration: 0.3 } }}
className="group relative glass-card rounded-3xl shadow-xl hover:shadow-2xl transition-all overflow-hidden"
>
{/* Gradient Background */}
<div className={`absolute inset-0 rounded-3xl bg-gradient-to-br ${useCase.gradient} opacity-0 group-hover:opacity-100 transition-opacity`} />
<div className="relative z-10 p-8 space-y-6">
{/* Icon */}
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
transition={{ duration: 0.2 }}
className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[hsl(var(--${useCase.color}))]/10 text-[hsl(var(--${useCase.color}))] border border-[hsl(var(--${useCase.color}))]/20`}
transition={{ delay: i * 0.15 }}
className="group relative glass-card rounded-3xl p-8 shadow-xl transition-all"
>
<div className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[hsl(var(--${useCase.color}))]/10 text-[hsl(var(--${useCase.color}))] border border-[hsl(var(--${useCase.color}))]/20 mb-6`}>
{useCase.icon}
</motion.div>
{/* Title */}
<h3 className="text-2xl font-display font-bold text-foreground">
{useCase.title}
</h3>
{/* Problem Statement */}
<p className="text-sm font-semibold text-muted-foreground">
{useCase.problem}
</p>
{/* Animated Demo Visual */}
<div className="!mt-6 rounded-xl overflow-hidden border border-border/50 shadow-inner">
</div>
<h3 className="text-2xl font-display font-bold text-foreground mb-4">{useCase.title}</h3>
<p className="text-sm font-semibold text-muted-foreground mb-6">{useCase.problem}</p>
<div className="rounded-xl overflow-hidden border border-border/50 shadow-inner mb-6 h-[280px]">
{useCase.demoComponent}
</div>
{/* Example Scenario */}
<div className="pt-4 border-t border-border">
<p className="text-xs uppercase tracking-wider font-bold text-muted-foreground mb-2">
Example:
</p>
<p className="text-sm text-foreground leading-relaxed">
{useCase.example}
</p>
</div>
<p className="text-xs uppercase tracking-wider font-bold text-muted-foreground mb-2">Example:</p>
<p className="text-sm text-foreground leading-relaxed">{useCase.example}</p>
</div>
</motion.div>
))}
@ -550,7 +451,7 @@ export function UseCaseShowcase() {
}
// ============================================
// 3. HOW IT WORKS - 4 Stage Flow
// 3. HOW IT WORKS
// ============================================
export function HowItWorks() {
const stages = [
@ -561,77 +462,41 @@ export function HowItWorks() {
]
return (
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-4))] to-[hsl(var(--section-bg-5))] relative overflow-hidden">
{/* Subtle Diagonal Stripe Decoration */}
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, hsl(var(--primary)), hsl(var(--primary)) 2px, transparent 2px, transparent 40px)' }} />
<section className="py-32 relative overflow-hidden">
<div className="mx-auto max-w-7xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="text-center mb-20"
>
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
How it works
</motion.h2>
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
Four simple steps to never miss an important change again.
</motion.p>
</motion.div>
<div className="text-center mb-20">
<h2 className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">How it works</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">Four simple steps to never miss an important change again.</p>
</div>
{/* Horizontal Flow */}
<div className="relative">
{/* Connecting Line */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-border to-transparent -translate-y-1/2 hidden lg:block" />
<div className="grid lg:grid-cols-4 gap-8 lg:gap-4">
<div className="grid lg:grid-cols-4 gap-8">
{stages.map((stage, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.5 }}
transition={{ delay: i * 0.1 }}
className="relative flex flex-col items-center text-center group"
>
{/* Large Number Background */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 text-8xl font-display font-bold text-border/20 pointer-events-none">
{String(i + 1).padStart(2, '0')}
</div>
{/* Circle Container */}
<div className="relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-full border-2 border-border bg-card shadow-lg group-hover:shadow-2xl group-hover:border-[hsl(var(--primary))] group-hover:bg-[hsl(var(--primary))]/5 transition-all">
<div className="text-[hsl(var(--primary))]">
{stage.icon}
<div className="relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-full border-2 border-border bg-card shadow-lg transition-all group-hover:border-[hsl(var(--primary))]">
<div className="text-[hsl(var(--primary))]">{stage.icon}</div>
</div>
</div>
{/* Text */}
<h3 className="text-lg font-bold text-foreground mb-2">
{stage.title}
</h3>
<p className="text-sm text-muted-foreground max-w-[200px]">
{stage.desc}
</p>
{/* Arrow (not on last) */}
{i < stages.length - 1 && (
<div className="hidden lg:block absolute top-10 -right-4 text-border">
<ChevronRight className="h-6 w-6" />
</div>
)}
<h3 className="text-lg font-bold text-foreground mb-2">{stage.title}</h3>
<p className="text-sm text-muted-foreground max-w-[200px]">{stage.desc}</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
)
}
// ============================================
// 4. DIFFERENTIATORS - Why We're Better
// 4. DIFFERENTIATORS
// ============================================
export function Differentiators() {
const features = [
@ -640,31 +505,19 @@ export function Differentiators() {
{ feature: 'Integrations', others: 'Email only', us: 'Slack, Webhooks, Teams', icon: <Slack className="h-5 w-5" /> },
{ feature: 'History & Proof', others: '7-30 days', us: 'Unlimited snapshots', icon: <History className="h-5 w-5" /> },
{ feature: 'Setup Time', others: '15+ min', us: '2 minutes', icon: <Zap className="h-5 w-5" /> },
{ feature: 'Pricing', others: '$29-99/mo', us: 'Fair pay-per-use', icon: <Shield className="h-5 w-5" /> }
{ feature: 'Pricing', others: '$29-99/mo', us: 'Free plan + fair scaling', icon: <Shield className="h-5 w-5" /> }
]
return (
<section className="py-32 bg-[hsl(var(--section-bg-5))] relative overflow-hidden">
{/* Radial Gradient Overlay */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,hsl(var(--teal))_0%,transparent_50%)] opacity-5" />
<section className="py-32 relative overflow-hidden">
<div className="mx-auto max-w-6xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="text-center mb-20"
>
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
Why we're{' '}
<span className="text-[hsl(var(--teal))]">different</span>
</motion.h2>
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
Not all monitoring tools are created equal. Here's what sets us apart.
</motion.p>
</motion.div>
<div className="text-center mb-20">
<h2 className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
{"Why we're"} <span className="text-[hsl(var(--teal))]">different</span>
</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">{"Not all monitoring tools are created equal. Here's what sets us apart."}</p>
</div>
{/* Feature Cards Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((item, i) => (
<motion.div
@ -672,20 +525,13 @@ export function Differentiators() {
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05, duration: 0.4 }}
className="group relative glass-card rounded-2xl p-6 hover:border-[hsl(var(--teal))]/30 hover:shadow-xl transition-all hover:-translate-y-1"
transition={{ delay: i * 0.05 }}
className="glass-card rounded-2xl p-6 hover:border-[hsl(var(--teal))]/30 transition-all"
>
{/* Icon */}
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))] mb-4 group-hover:scale-110 transition-transform">
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))] mb-4">
{item.icon}
</div>
{/* Feature Name */}
<h3 className="text-lg font-bold text-foreground mb-4">
{item.feature}
</h3>
{/* Comparison */}
<h3 className="text-lg font-bold text-foreground mb-4">{item.feature}</h3>
<div className="space-y-3">
<div className="flex items-start gap-2">
<span className="text-xs uppercase tracking-wider font-bold text-muted-foreground flex-shrink-0 mt-0.5">Others:</span>
@ -705,69 +551,36 @@ export function Differentiators() {
}
// ============================================
// 6. FINAL CTA - Get Started
// 6. FINAL CTA
// ============================================
export function FinalCTA() {
return (
<section className="relative overflow-hidden py-32">
{/* Animated Gradient Mesh Background - More dramatic */}
<div className="absolute inset-0 bg-gradient-to-br from-[hsl(var(--primary))]/30 via-[hsl(var(--burgundy))]/20 to-[hsl(var(--teal))]/30 opacity-70" />
<div className="absolute inset-0 gradient-velvet opacity-90" />
<div className="absolute inset-0 grain-texture" />
{/* Animated Orbs - Enhanced */}
<motion.div
animate={{
scale: [1, 1.3, 1],
opacity: [0.4, 0.6, 0.4],
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-[60px]"
/>
<motion.div
animate={{
scale: [1, 1.2, 1],
opacity: [0.4, 0.5, 0.4],
rotate: [360, 180, 0]
}}
transition={{ duration: 15, repeat: Infinity, ease: "linear", delay: 2 }}
className="absolute bottom-1/4 -right-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--teal))] blur-[60px]"
/>
<div className="mx-auto max-w-4xl px-6 text-center relative z-10">
<motion.div
initial="hidden"
whileInView="visible"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="space-y-8"
>
{/* Headline */}
<motion.h2 variants={fadeInUp} className="text-5xl lg:text-6xl font-display font-bold leading-tight text-foreground">
Stop missing the changes{' '}
<span className="text-[hsl(var(--primary))]">that matter.</span>
</motion.h2>
{/* Subheadline */}
<motion.p variants={fadeInUp} custom={1} className="text-xl lg:text-2xl text-muted-foreground max-w-2xl mx-auto">
<h2 className="text-5xl lg:text-6xl font-display font-bold leading-tight text-foreground">
Stop missing the changes <span className="text-[hsl(var(--primary))]">that matter.</span>
</h2>
<p className="text-xl lg:text-2xl text-muted-foreground max-w-2xl mx-auto">
Join the waitlist and be first to experience monitoring that actually works.
</motion.p>
{/* Waitlist Form */}
<motion.div variants={fadeInUp} custom={2} className="pt-4 max-w-lg mx-auto">
</p>
<div className="pt-4 max-w-lg mx-auto">
<WaitlistForm />
</motion.div>
{/* Social Proof Indicator */}
<motion.div
variants={fadeInUp}
custom={3}
className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground"
>
</div>
<div className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
<span>Early access</span>
<span>Join the waitlist for early access</span>
</div>
</div>
</motion.div>
</motion.div>
</div>
</section>

View File

@ -47,7 +47,7 @@ export function LiveSerpPreview() {
}
return (
<section className="py-24 bg-gradient-to-b from-background to-[hsl(var(--section-bg-2))] relative overflow-hidden">
<section className="py-24 relative overflow-hidden">
{/* Background Gradients */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,hsl(var(--primary))_0%,transparent_50%)] opacity-5" />
@ -111,6 +111,7 @@ export function LiveSerpPreview() {
</AnimatePresence>
{/* Result Preview */}
<div className="min-h-[260px]">
<AnimatePresence mode="wait">
{data && (
<motion.div
@ -125,7 +126,9 @@ export function LiveSerpPreview() {
<div className="flex items-center gap-3 mb-3">
<div className="p-1 rounded-full bg-gray-100 dark:bg-gray-800">
{data.favicon ? (
<img src={data.favicon} alt="Favicon" className="w-6 h-6 object-contain" />
// Dynamic external favicon URLs are not known at build time.
// eslint-disable-next-line @next/next/no-img-element
<img src={data.favicon} alt="Favicon" className="w-6 h-6 object-contain" width="24" height="24" />
) : (
<Globe className="w-6 h-6 text-gray-400" />
)}
@ -173,6 +176,7 @@ export function LiveSerpPreview() {
</AnimatePresence>
</div>
</div>
</div>
</section>
)
}

View File

@ -4,8 +4,24 @@ import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { FileCheck, Check } from 'lucide-react'
function resolveHsl(cssVar: string): string {
if (typeof window === 'undefined') return 'transparent'
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
return value ? `hsl(${value})` : 'transparent'
}
export function PolicyDemoVisual() {
const [phase, setPhase] = useState(0)
const [colors, setColors] = useState({ burgundy: '#993350', teal: '#2e6b6a', border: '#27272a', mutedFg: '#aba49d' })
useEffect(() => {
setColors({
burgundy: resolveHsl('--burgundy'),
teal: resolveHsl('--teal'),
border: resolveHsl('--border'),
mutedFg: resolveHsl('--muted-foreground'),
})
}, [])
useEffect(() => {
const interval = setInterval(() => {
@ -15,7 +31,7 @@ export function PolicyDemoVisual() {
}, [])
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--burgundy))]/5 rounded-xl p-4 overflow-hidden">
<div className="relative h-full bg-gradient-to-br from-background via-background to-[hsl(var(--burgundy))]/5 rounded-xl p-4 overflow-hidden">
{/* Document Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
@ -25,9 +41,9 @@ export function PolicyDemoVisual() {
<motion.div
className="px-2 py-0.5 rounded-full border text-[9px] font-bold"
animate={{
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.1)' : 'transparent',
color: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--muted-foreground))'
borderColor: phase === 1 ? colors.teal : colors.border,
backgroundColor: phase === 1 ? `${colors.teal}1a` : 'rgba(0,0,0,0)',
color: phase === 1 ? colors.teal : colors.mutedFg
}}
transition={{ duration: 0.5 }}
>
@ -39,9 +55,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 ? 'hsl(var(--burgundy))' : '#27272a',
borderColor: phase === 1 ? colors.burgundy : colors.border,
boxShadow: phase === 1
? '0 0 20px hsl(var(--burgundy) / 0.2)'
? `0 0 20px ${colors.burgundy}33`
: '0 1px 3px rgba(0,0,0,0.2)'
}}
transition={{ duration: 0.5 }}
@ -63,10 +79,10 @@ export function PolicyDemoVisual() {
>
<motion.p
animate={{
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
backgroundColor: phase === 1 ? `${colors.burgundy}1a` : 'rgba(0,0,0,0)',
paddingLeft: phase === 1 ? '4px' : '0px',
paddingRight: phase === 1 ? '4px' : '0px',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit',
color: phase === 1 ? colors.burgundy : 'inherit',
fontWeight: phase === 1 ? 600 : 400
}}
transition={{ duration: 0.4 }}

View File

@ -4,8 +4,23 @@ import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { TrendingDown, TrendingUp } from 'lucide-react'
function resolveHsl(cssVar: string): string {
if (typeof window === 'undefined') return 'transparent'
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim()
return value ? `hsl(${value})` : 'transparent'
}
export function SEODemoVisual() {
const [phase, setPhase] = useState(0)
const [colors, setColors] = useState({ burgundy: '#993350', border: '#27272a', background: '#0c0b09' })
useEffect(() => {
setColors({
burgundy: resolveHsl('--burgundy'),
border: resolveHsl('--border'),
background: resolveHsl('--background'),
})
}, [])
useEffect(() => {
const interval = setInterval(() => {
@ -18,7 +33,7 @@ export function SEODemoVisual() {
const newMeta = "Best enterprise software for teams of all sizes. Try free for 30 days. Now with AI-powered analytics and real-time collaboration."
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--teal))]/5 rounded-xl p-4 overflow-hidden">
<div className="relative h-full bg-gradient-to-br from-background via-background to-[hsl(var(--teal))]/5 rounded-xl p-4 overflow-hidden">
{/* SERP Result */}
<div className="space-y-4">
{/* Ranking Indicator */}
@ -29,8 +44,8 @@ export function SEODemoVisual() {
<motion.div
className="flex items-center gap-1 px-2 py-1 rounded-full bg-background border border-border"
animate={{
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--burgundy))',
backgroundColor: phase === 0 ? 'hsl(var(--background))' : 'hsl(var(--burgundy) / 0.1)'
borderColor: phase === 0 ? colors.border : colors.burgundy,
backgroundColor: phase === 0 ? colors.background : `${colors.burgundy}1a`
}}
transition={{ duration: 0.5 }}
>
@ -59,10 +74,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' : 'hsl(var(--burgundy))',
borderColor: phase === 0 ? colors.border : colors.burgundy,
boxShadow: phase === 0
? '0 1px 3px rgba(0,0,0,0.2)'
: '0 0 20px hsl(var(--burgundy) / 0.2)'
: `0 0 20px ${colors.burgundy}33`
}}
transition={{ duration: 0.5 }}
>
@ -86,8 +101,8 @@ export function SEODemoVisual() {
>
<motion.span
animate={{
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit'
backgroundColor: phase === 1 ? `${colors.burgundy}1a` : 'rgba(0,0,0,0)',
color: phase === 1 ? colors.burgundy : 'inherit'
}}
transition={{ duration: 0.5 }}
className="inline-block rounded px-0.5"

View File

@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Check, ArrowRight, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { MagneticButton } from './MagneticElements'
interface WaitlistFormProps {
id?: string
@ -144,7 +145,7 @@ export function WaitlistForm({ id }: WaitlistFormProps) {
transition={{ delay: 0.3 }}
className="mb-3 text-3xl font-display font-bold text-foreground"
>
You're on the list!
{"You're on the list!"}
</motion.h3>
<motion.p
@ -203,11 +204,12 @@ export function WaitlistForm({ id }: WaitlistFormProps) {
</motion.div>
{/* Submit Button */}
<MagneticButton strength={0.3}>
<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"
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 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
@ -221,6 +223,7 @@ export function WaitlistForm({ id }: WaitlistFormProps) {
</>
)}
</Button>
</MagneticButton>
</div>
{/* Error Message - Visibility Improved */}

View File

@ -10,16 +10,16 @@ export function Footer() {
<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" />
<Image src="/logo.png" alt="SiteChangeMonitor Logo" fill className="object-contain" />
</div>
<span className="text-lg font-bold text-foreground">Alertify</span>
<span className="text-lg font-bold text-foreground">SiteChangeMonitor</span>
</div>
<p className="text-muted-foreground max-w-xs mb-6">
<p className="mb-6 max-w-xs text-muted-foreground">
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">
<div className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full bg-secondary text-muted-foreground transition-colors hover:bg-border hover:text-foreground">
<Globe className="h-4 w-4" />
</div>
</div>
@ -28,33 +28,33 @@ export function Footer() {
<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>
<li><Link href="/features" className="transition-colors hover:text-primary">Features</Link></li>
<li><Link href="/use-cases" className="transition-colors hover:text-primary">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>
<li><Link href="/blog" className="transition-colors hover:text-primary">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>
<li><Link href="/privacy" className="transition-colors hover:text-primary">Privacy</Link></li>
<li><Link href="/admin" className="text-xs opacity-50 transition-colors hover:text-primary">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>
<p>(c) 2026 SiteChangeMonitor. 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 className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
System Operational
</div>

View File

@ -9,7 +9,8 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, hint, id, ...props }, ref) => {
const inputId = id || React.useId()
const generatedId = React.useId()
const inputId = id ?? generatedId
return (
<div className="w-full">

View File

@ -10,7 +10,8 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, hint, id, options, ...props }, ref) => {
const selectId = id || React.useId()
const generatedId = React.useId()
const selectId = id ?? generatedId
return (
<div className="w-full">

BIN
frontend/lint_output.txt Normal file

Binary file not shown.

57
frontend/middleware.ts Normal file
View File

@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
const LANDING_ALLOWED_PATHS = ['/', '/blog', '/privacy', '/admin', '/use-cases', '/features']
const NEXT_METADATA_PATHS = [
'/favicon.ico',
'/icon',
'/apple-icon',
'/opengraph-image',
'/twitter-image',
]
function isAllowedLandingPath(pathname: string): boolean {
if (pathname === '/') return true
return LANDING_ALLOWED_PATHS
.filter((path) => path !== '/')
.some((path) => pathname === path || pathname.startsWith(`${path}/`))
}
function isStaticOrMetadataPath(pathname: string): boolean {
// Never treat API routes as static paths
if (pathname.startsWith('/api')) return false
if (pathname.startsWith('/_next')) return true
if (/\.[^/]+$/.test(pathname)) return true
return NEXT_METADATA_PATHS.some(
(path) => pathname === path || pathname.startsWith(`${path}/`)
)
}
export function middleware(request: NextRequest) {
if (process.env.NEXT_PUBLIC_LANDING_ONLY_MODE !== 'true') {
return NextResponse.next()
}
const { pathname } = request.nextUrl
if (isStaticOrMetadataPath(pathname)) {
return NextResponse.next()
}
if (isAllowedLandingPath(pathname)) {
return NextResponse.next()
}
const redirectUrl = request.nextUrl.clone()
redirectUrl.pathname = '/'
redirectUrl.search = ''
return NextResponse.redirect(redirectUrl, 307)
}
export const config = {
matcher: '/:path*',
}

View File

@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
optimizeFonts: false,
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002',
},

View File

@ -1091,7 +1091,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@ -1513,7 +1512,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1964,7 +1962,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2477,7 +2474,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3255,7 +3251,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -3424,7 +3419,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -4939,7 +4933,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -5842,7 +5835,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6076,7 +6068,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -6109,7 +6100,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -6985,6 +6975,24 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -7062,7 +7070,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -7249,7 +7256,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -0,0 +1,9 @@
User-agent: *
Allow: /
Disallow: /dashboard
Disallow: /monitors
Disallow: /settings
Disallow: /admin
Disallow: /api
Sitemap: https://sitechangemonitor.com/sitemap.xml

File diff suppressed because one or more lines are too long