Production ready
This commit is contained in:
parent
fd6e7c44e1
commit
7814548e11
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 0
|
||||
completion_promise: null
|
||||
started_at: "2026-01-17T14:40:37Z"
|
||||
---
|
||||
|
||||
Implement website monitor features in priority order:
|
||||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 0
|
||||
completion_promise: null
|
||||
started_at: "2026-01-17T14:40:37Z"
|
||||
---
|
||||
|
||||
Implement website monitor features in priority order:
|
||||
|
|
|
|||
35
README.md
35
README.md
|
|
@ -96,20 +96,35 @@ npm run migrate
|
|||
|
||||
### Backend (.env)
|
||||
```env
|
||||
PORT=3001
|
||||
DATABASE_URL=postgresql://admin:admin123@localhost:5432/website_monitor
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=your-secret-key
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-api-key
|
||||
PORT=3001
|
||||
DATABASE_URL=postgresql://admin:admin123@localhost:5432/website_monitor
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=your-secret-key
|
||||
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_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
|
||||
|
||||
|
|
|
|||
29
SETUP.md
29
SETUP.md
|
|
@ -154,15 +154,34 @@ EMAIL_FROM=alerts@yourdomain.com
|
|||
|
||||
For development, use [Mailtrap.io](https://mailtrap.io) (free).
|
||||
|
||||
### Adjust Plan Limits
|
||||
Edit `backend/.env`:
|
||||
### Adjust Plan Limits
|
||||
Edit `backend/.env`:
|
||||
|
||||
```env
|
||||
MAX_MONITORS_FREE=5
|
||||
MAX_MONITORS_PRO=50
|
||||
MIN_FREQUENCY_FREE=60 # minutes
|
||||
MIN_FREQUENCY_PRO=5 # minutes
|
||||
```
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ SMTP_PORT=587
|
|||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
|
||||
# App
|
||||
APP_URL=http://localhost:3000
|
||||
API_URL=http://localhost:3002
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -47,10 +47,10 @@ export function getMaxMonitors(plan: UserPlan): number {
|
|||
/**
|
||||
* Check if a plan has a specific feature
|
||||
*/
|
||||
export function hasFeature(plan: UserPlan, feature: string): boolean {
|
||||
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
|
||||
return planConfig.features.includes(feature as any);
|
||||
}
|
||||
export function hasFeature(plan: UserPlan, feature: string): boolean {
|
||||
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
|
||||
return (planConfig.features as readonly string[]).includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook retry configuration
|
||||
|
|
|
|||
|
|
@ -53,11 +53,13 @@ export const query = async <T extends QueryResultRow = any>(
|
|||
return result;
|
||||
};
|
||||
|
||||
export const getClient = () => pool.connect();
|
||||
|
||||
// User queries
|
||||
export const db = {
|
||||
users: {
|
||||
export const getClient = () => pool.connect();
|
||||
|
||||
// User queries
|
||||
export const db = {
|
||||
query,
|
||||
|
||||
users: {
|
||||
async create(email: string, passwordHash: string): Promise<User> {
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
|
||||
|
|
@ -455,12 +457,12 @@ export const db = {
|
|||
return toCamelCase<any>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findLatestByMonitorId(monitorId: string, limit = 50): Promise<any[]> {
|
||||
// Gets the latest check per keyword for this monitor
|
||||
// Using DISTINCT ON is efficient in Postgres
|
||||
const result = await query(
|
||||
`SELECT DISTINCT ON (keyword) *
|
||||
FROM monitor_rankings
|
||||
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(
|
||||
`SELECT DISTINCT ON (keyword) *
|
||||
FROM monitor_rankings
|
||||
WHERE monitor_id = $1
|
||||
ORDER BY keyword, created_at DESC`,
|
||||
[monitorId]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations
|
||||
|
|
|
|||
|
|
@ -8,8 +8,28 @@ import { authMiddleware } from './middleware/auth';
|
|||
import { apiLimiter, authLimiter } from './middleware/rateLimiter';
|
||||
import { startWorker, shutdownScheduler, getSchedulerStats } from './services/scheduler';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
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({
|
||||
|
|
@ -24,13 +44,27 @@ app.use(express.urlencoded({ extended: true }));
|
|||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Request logging
|
||||
app.use((req, _res, next) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (_req, res) => {
|
||||
app.use((req, _res, next) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
|
||||
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();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
|
|
@ -62,8 +96,9 @@ app.use((req, res) => {
|
|||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
void _next;
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
|
|
|
|||
|
|
@ -2,78 +2,82 @@ import { Router } from 'express';
|
|||
import axios from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const previewSchema = z.object({
|
||||
url: z.string().min(1)
|
||||
});
|
||||
|
||||
router.post('/meta-preview', async (req, res) => {
|
||||
try {
|
||||
let { url } = previewSchema.parse(req.body);
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Cache-Control': 'max-age=0'
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const previewSchema = z.object({
|
||||
url: z.string().min(1)
|
||||
});
|
||||
|
||||
router.post('/meta-preview', async (req, res) => {
|
||||
try {
|
||||
let { url } = previewSchema.parse(req.body);
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'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
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('title').text() || $('meta[property="og:title"]').attr('content') || '';
|
||||
const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
|
||||
|
||||
// Attempt to find favicon
|
||||
let favicon = '';
|
||||
const linkIcon = $('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]').attr('href');
|
||||
if (linkIcon) {
|
||||
if (linkIcon.startsWith('http')) {
|
||||
favicon = linkIcon;
|
||||
} else if (linkIcon.startsWith('//')) {
|
||||
favicon = `https:${linkIcon}`;
|
||||
} else {
|
||||
const urlObj = new URL(url);
|
||||
favicon = `${urlObj.protocol}//${urlObj.host}${linkIcon.startsWith('/') ? '' : '/'}${linkIcon}`;
|
||||
}
|
||||
} else {
|
||||
const urlObj = new URL(url);
|
||||
favicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
|
||||
}
|
||||
|
||||
res.json({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
favicon,
|
||||
url: url
|
||||
});
|
||||
|
||||
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const title = $('title').text() || $('meta[property="og:title"]').attr('content') || '';
|
||||
const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
|
||||
|
||||
// Attempt to find favicon
|
||||
let favicon = '';
|
||||
const linkIcon = $('link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]').attr('href');
|
||||
if (linkIcon) {
|
||||
if (linkIcon.startsWith('http')) {
|
||||
favicon = linkIcon;
|
||||
} else if (linkIcon.startsWith('//')) {
|
||||
favicon = `https:${linkIcon}`;
|
||||
} else {
|
||||
const urlObj = new URL(url);
|
||||
favicon = `${urlObj.protocol}//${urlObj.host}${linkIcon.startsWith('/') ? '' : '/'}${linkIcon}`;
|
||||
}
|
||||
} else {
|
||||
const urlObj = new URL(url);
|
||||
favicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
|
||||
}
|
||||
|
||||
res.json({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
favicon,
|
||||
url: url
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Meta preview error:', error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Invalid URL provided' });
|
||||
res.status(400).json({ error: 'Invalid URL provided' });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to fetch page metadata' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
export const toolsRouter = router;
|
||||
|
||||
export const toolsRouter = router;
|
||||
|
|
|
|||
|
|
@ -1,131 +1,134 @@
|
|||
import { Router } from 'express';
|
||||
import { query } from '../db';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Validation schema
|
||||
const waitlistSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
source: z.string().optional().default('landing_page'),
|
||||
referrer: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/waitlist - Add email to waitlist
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const data = waitlistSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await query(
|
||||
'SELECT id FROM waitlist_leads WHERE email = $1',
|
||||
[data.email.toLowerCase()]
|
||||
);
|
||||
|
||||
import { Router } from 'express';
|
||||
import { query } from '../db';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Validation schema
|
||||
const waitlistSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
source: z.string().optional().default('landing_page'),
|
||||
referrer: z.string().optional(),
|
||||
});
|
||||
|
||||
// POST /api/waitlist - Add email to waitlist
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const data = waitlistSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await query(
|
||||
'SELECT id FROM waitlist_leads WHERE email = $1',
|
||||
[data.email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Already on waitlist - return success anyway (don't reveal they're already signed up)
|
||||
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
|
||||
await query(
|
||||
'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)',
|
||||
[data.email.toLowerCase(), data.source, data.referrer || null]
|
||||
);
|
||||
|
||||
// Get current position (total count)
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const position = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'You\'re on the list!',
|
||||
position,
|
||||
});
|
||||
|
||||
// Insert new lead
|
||||
await query(
|
||||
'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)',
|
||||
[data.email.toLowerCase(), data.source, data.referrer || null]
|
||||
);
|
||||
|
||||
// Get current position (total count)
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const position = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'You\'re on the list!',
|
||||
position,
|
||||
});
|
||||
} 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);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'server_error',
|
||||
message: 'Failed to join waitlist. Please try again.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/waitlist/count - Get current waitlist count (public)
|
||||
router.get('/count', async (_req, res) => {
|
||||
try {
|
||||
const result = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const count = parseInt(result.rows[0].count, 10);
|
||||
|
||||
// Add a base number to make it look more impressive at launch
|
||||
const displayCount = count + 430; // Starting with "430+ waiting"
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: displayCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Waitlist count error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
count: 430, // Fallback to base number
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/waitlist/admin - Get waitlist leads (Admin only)
|
||||
router.get('/admin', async (req, res) => {
|
||||
try {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
const providedPassword = req.headers['x-admin-password'];
|
||||
|
||||
|
||||
console.error('Waitlist signup error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'server_error',
|
||||
message: 'Failed to join waitlist. Please try again.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/waitlist/count - Get current waitlist count (public)
|
||||
router.get('/count', async (_req, res) => {
|
||||
try {
|
||||
const result = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const count = parseInt(result.rows[0].count, 10);
|
||||
|
||||
// Add a base number to make it look more impressive at launch
|
||||
const displayCount = count + 430; // Starting with "430+ waiting"
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: displayCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Waitlist count error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
count: 430, // Fallback to base number
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/waitlist/admin - Get waitlist leads (Admin only)
|
||||
router.get('/admin', async (req, res) => {
|
||||
try {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
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
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get leads
|
||||
const leadsResult = await query(
|
||||
'SELECT * FROM waitlist_leads ORDER BY created_at DESC LIMIT 100'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
total,
|
||||
leads: leadsResult.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Waitlist admin error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
// Get stats
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get leads
|
||||
const leadsResult = await query(
|
||||
'SELECT * FROM waitlist_leads ORDER BY created_at DESC LIMIT 100'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
total,
|
||||
leads: leadsResult.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Waitlist admin error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { Queue, Worker } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import nodemailer from 'nodemailer';
|
||||
import db from '../db';
|
||||
import { ConnectionOptions, Queue, Worker } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import nodemailer from 'nodemailer';
|
||||
import db from '../db';
|
||||
|
||||
// Redis connection (reuse from main scheduler)
|
||||
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
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,
|
||||
export const digestQueue = new Queue('change-digests', {
|
||||
connection: queueConnection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 10,
|
||||
|
|
@ -263,11 +264,11 @@ export function startDigestWorker(): Worker {
|
|||
const { interval } = job.data;
|
||||
await processDigests(interval);
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
}
|
||||
);
|
||||
{
|
||||
connection: queueConnection,
|
||||
concurrency: 1,
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`[Digest] Job ${job.id} completed`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { Queue, Worker, QueueEvents } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import { checkMonitor } from './monitor';
|
||||
import { Monitor } from '../db';
|
||||
import { ConnectionOptions, Queue, Worker, QueueEvents } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import { checkMonitor } from './monitor';
|
||||
import { Monitor } from '../types';
|
||||
|
||||
// Redis connection
|
||||
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
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,
|
||||
export const monitorQueue = new Queue('monitor-checks', {
|
||||
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`);
|
||||
|
|
@ -130,11 +131,11 @@ export function startWorker(): Worker {
|
|||
throw error; // Re-throw to mark job as failed
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 5, // Process up to 5 monitors concurrently
|
||||
}
|
||||
);
|
||||
{
|
||||
connection: queueConnection,
|
||||
concurrency: 5, // Process up to 5 monitors concurrently
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`[Worker] Job ${job.id} completed`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
declare module 'serpapi' {
|
||||
export function getJson(
|
||||
params: Record<string, unknown>,
|
||||
callback: (json: any) => void
|
||||
): void;
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
|
|||
1603
findings.md
1603
findings.md
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -104,13 +104,13 @@ export default function ForgotPasswordPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<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>
|
||||
<li>Wait a few minutes and try again</li>
|
||||
</ul>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<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>
|
||||
<li>Wait a few minutes and try again</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,46 +1,230 @@
|
|||
import type { Metadata } from 'next'
|
||||
import { Inter_Tight, Space_Grotesk } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
// Body/UI font - straff, modern, excellent readability
|
||||
const interTight = Inter_Tight({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Headline font - geometric, futuristic, "smart" look
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
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.',
|
||||
}
|
||||
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
|
||||
import { CookieBanner } from '@/components/compliance/CookieBanner'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
|
||||
<body className={interTight.className}>
|
||||
<PostHogProvider>
|
||||
<Providers>{children}</Providers>
|
||||
<CookieBanner />
|
||||
<Toaster richColors position="top-right" />
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
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'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Headline font - geometric, futuristic, "smart" look
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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'
|
||||
|
||||
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,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
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 />
|
||||
<Toaster richColors position="top-right" />
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,18 +42,18 @@ export default function LoginPage() {
|
|||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Alertify Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="SiteChangeMonitor Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your Alertify account
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your SiteChangeMonitor account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
|
|
@ -103,13 +103,13 @@ export default function LoginPage() {
|
|||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{"Don't have an account?"}{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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 "Add Keyword" to create one.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newMonitor.keywordRules.map((rule, index) => (
|
||||
|
|
@ -895,10 +895,10 @@ 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}"
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-primary/40 pl-2 line-clamp-2">
|
||||
"{monitor.recentSnapshots[0].summary}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sparkline & Importance */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
<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"
|
||||
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
Join Waitlist
|
||||
</Button>
|
||||
<MagneticButton strength={0.4}>
|
||||
<Button
|
||||
size="sm"
|
||||
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,58 +171,64 @@ export default function Home() {
|
|||
{/* Hero Section */}
|
||||
<HeroSection />
|
||||
|
||||
{/* Live SERP Preview Tool */}
|
||||
<LiveSerpPreview />
|
||||
{/* Continuous Gradient Wrapper for Content */}
|
||||
<div className="gradient-ivory">
|
||||
{/* Live SERP Preview Tool */}
|
||||
<LiveSerpPreview />
|
||||
|
||||
{/* Use Case Showcase */}
|
||||
<UseCaseShowcase />
|
||||
{/* Use Case Showcase */}
|
||||
<UseCaseShowcase />
|
||||
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Differentiators */}
|
||||
<Differentiators />
|
||||
{/* Differentiators */}
|
||||
<Differentiators />
|
||||
|
||||
{/* FAQ Section */}
|
||||
< section id="faq" className="border-t border-border/40 py-24 bg-background" >
|
||||
<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
|
||||
</h2>
|
||||
{/* FAQ Section */}
|
||||
<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
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="rounded-2xl border border-border bg-card overflow-hidden"
|
||||
initial={false}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||
className="flex w-full items-center justify-between p-6 text-left hover:bg-secondary/30 transition-colors"
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="rounded-2xl border border-border bg-card overflow-hidden"
|
||||
initial={false}
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.question}</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{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"
|
||||
>
|
||||
{faq.answer}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||
className="flex w-full items-center justify-between p-6 text-left hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.question}</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{openFaq === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
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>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
</section >
|
||||
</div>
|
||||
|
||||
{/* Final CTA */}
|
||||
<FinalCTA />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
<h3>4. Contact Us</h3>
|
||||
<p>
|
||||
If you have any questions about this privacy policy or our privacy practices, please contact us at:
|
||||
</p>
|
||||
<div className="p-4 bg-secondary/20 rounded-lg border border-border">
|
||||
<p className="font-semibold">Alertify Support</p>
|
||||
<p>Email: <a href="mailto:support@qrmaster.net" className="text-primary hover:underline">support@qrmaster.net</a></p>
|
||||
</div>
|
||||
</section>
|
||||
{/* 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 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
|
|
|||
|
|
@ -61,13 +61,13 @@ export default function RegisterPage() {
|
|||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Alertify Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="SiteChangeMonitor Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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't see your use case?</h2>
|
||||
<p className="text-muted-foreground mb-6">Join the waitlist and tell us what you need. We'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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 "Accept", you consent to our use of cookies.
|
||||
Read our <Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link>.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="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
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-muted-foreground dark:text-zinc-400 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>
|
||||
</div>
|
||||
<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">$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"
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
{/* 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 */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<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
|
||||
<div className="text-center mb-20">
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
transition={{ delay: i * 0.15 }}
|
||||
className="group relative glass-card rounded-3xl p-8 shadow-xl transition-all"
|
||||
>
|
||||
{/* 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`}
|
||||
>
|
||||
{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">
|
||||
{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>
|
||||
<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}
|
||||
</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>
|
||||
<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>
|
||||
</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,69 +462,33 @@ 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">
|
||||
{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 }}
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<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 }}
|
||||
className="relative flex flex-col items-center text-center group"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
|
@ -631,7 +496,7 @@ export function HowItWorks() {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// 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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,178 +1,182 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Search, Loader2, Globe, AlertCircle, ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
description: string
|
||||
favicon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function LiveSerpPreview() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [data, setData] = useState<PreviewData | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleAnalyze = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setData(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/tools/meta-preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch preview')
|
||||
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
setError('Could not analyze this URL. Please check if it represents a valid, publicly accessible website.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-gradient-to-b from-background to-[hsl(var(--section-bg-2))] 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" />
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 relative z-10">
|
||||
<div className="text-center mb-12">
|
||||
<div 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">
|
||||
<Search className="h-4 w-4" />
|
||||
Free Tool
|
||||
</div>
|
||||
<h2 className="text-4xl font-display font-bold text-foreground mb-4">
|
||||
See how Google sees you
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Enter your URL to get an instant SERP preview.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
{/* Input Form */}
|
||||
<form onSubmit={handleAnalyze} className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-[hsl(var(--primary))] to-[hsl(var(--teal))] rounded-xl opacity-20 group-hover:opacity-40 blur transition duration-500" />
|
||||
<div className="relative flex gap-2 p-2 bg-card border border-border rounded-xl shadow-xl">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="website.com"
|
||||
className="pl-9 h-12 bg-transparent border-none shadow-none focus-visible:ring-0 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !url}
|
||||
className="h-12 px-6 bg-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/90 text-white font-semibold rounded-lg transition-all"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Analyze'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center gap-2 p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-sm"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Search, Loader2, Globe, AlertCircle, ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PreviewData {
|
||||
title: string
|
||||
description: string
|
||||
favicon: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function LiveSerpPreview() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [data, setData] = useState<PreviewData | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleAnalyze = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
setData(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/tools/meta-preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch preview')
|
||||
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
} catch (err) {
|
||||
setError('Could not analyze this URL. Please check if it represents a valid, publicly accessible website.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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" />
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 relative z-10">
|
||||
<div className="text-center mb-12">
|
||||
<div 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">
|
||||
<Search className="h-4 w-4" />
|
||||
Free Tool
|
||||
</div>
|
||||
<h2 className="text-4xl font-display font-bold text-foreground mb-4">
|
||||
See how Google sees you
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Enter your URL to get an instant SERP preview.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl mx-auto space-y-8">
|
||||
{/* Input Form */}
|
||||
<form onSubmit={handleAnalyze} className="relative group">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-[hsl(var(--primary))] to-[hsl(var(--teal))] rounded-xl opacity-20 group-hover:opacity-40 blur transition duration-500" />
|
||||
<div className="relative flex gap-2 p-2 bg-card border border-border rounded-xl shadow-xl">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="website.com"
|
||||
className="pl-9 h-12 bg-transparent border-none shadow-none focus-visible:ring-0 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !url}
|
||||
className="h-12 px-6 bg-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/90 text-white font-semibold rounded-lg transition-all"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Analyze'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex items-center gap-2 p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500 text-sm"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Result Preview */}
|
||||
<AnimatePresence mode="wait">
|
||||
{data && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Google Result Card */}
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-[#1a1c20] border border-border shadow-2xl">
|
||||
<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" />
|
||||
) : (
|
||||
<Globe className="w-6 h-6 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[#202124] dark:text-[#dadce0] font-normal leading-tight">
|
||||
{new URL(data.url).hostname}
|
||||
</span>
|
||||
<span className="text-xs text-[#5f6368] dark:text-[#bdc1c6] leading-tight">
|
||||
{data.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl text-[#1a0dab] dark:text-[#8ab4f8] font-normal hover:underline cursor-pointer mb-1 leading-snug break-words">
|
||||
{data.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[#4d5156] dark:text-[#bdc1c6] leading-normal">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upsell / CTA */}
|
||||
<div className="min-h-[260px]">
|
||||
<AnimatePresence mode="wait">
|
||||
{data && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-center space-y-4"
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="inline-block p-4 rounded-2xl bg-[hsl(var(--primary))]/5 border border-[hsl(var(--primary))]/20">
|
||||
<p className="text-sm font-medium text-foreground mb-3">
|
||||
Want to know when this changes?
|
||||
{/* Google Result Card */}
|
||||
<div className="p-6 rounded-xl bg-white dark:bg-[#1a1c20] border border-border shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-1 rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
{data.favicon ? (
|
||||
// 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" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-[#202124] dark:text-[#dadce0] font-normal leading-tight">
|
||||
{new URL(data.url).hostname}
|
||||
</span>
|
||||
<span className="text-xs text-[#5f6368] dark:text-[#bdc1c6] leading-tight">
|
||||
{data.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl text-[#1a0dab] dark:text-[#8ab4f8] font-normal hover:underline cursor-pointer mb-1 leading-snug break-words">
|
||||
{data.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[#4d5156] dark:text-[#bdc1c6] leading-normal">
|
||||
{data.description}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10"
|
||||
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
Get notified on changes
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Upsell / CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="text-center space-y-4"
|
||||
>
|
||||
<div className="inline-block p-4 rounded-2xl bg-[hsl(var(--primary))]/5 border border-[hsl(var(--primary))]/20">
|
||||
<p className="text-sm font-medium text-foreground mb-3">
|
||||
Want to know when this changes?
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10"
|
||||
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
Get notified on changes
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -138,14 +139,14 @@ export function WaitlistForm({ id }: WaitlistFormProps) {
|
|||
</motion.div>
|
||||
|
||||
{/* Success Message */}
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-3 text-3xl font-display font-bold text-foreground"
|
||||
>
|
||||
You're on the list!
|
||||
</motion.h3>
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-3 text-3xl font-display font-bold text-foreground"
|
||||
>
|
||||
{"You're on the list!"}
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
@ -203,24 +204,26 @@ export function WaitlistForm({ id }: WaitlistFormProps) {
|
|||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</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 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
{/* Error Message - Visibility Improved */}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,65 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background py-12 text-sm">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="relative h-8 w-8">
|
||||
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">Alertify</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-xs mb-6">
|
||||
The modern platform for uptime monitoring, change detection, and performance tracking.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Social icons placeholders */}
|
||||
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/#features" className="hover:text-primary transition-colors">Features</Link></li>
|
||||
<li><Link href="/#use-cases" className="hover:text-primary transition-colors">Use Cases</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/blog" className="hover:text-primary transition-colors">Blog</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy</Link></li>
|
||||
<li><Link href="/admin" className="hover:text-primary transition-colors opacity-50 text-xs">Admin</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
||||
<p>© 2026 Alertify. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background py-12 text-sm">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="relative h-8 w-8">
|
||||
<Image src="/logo.png" alt="SiteChangeMonitor Logo" fill className="object-contain" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">SiteChangeMonitor</span>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/features" className="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="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="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>(c) 2026 SiteChangeMonitor. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
// Check for stored preference or system preference
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
setTheme(stored)
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme, mounted])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-lg bg-secondary/50 text-muted-foreground">
|
||||
<Sun className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={toggleTheme}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="relative p-2 rounded-lg bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: theme === 'dark' ? 180 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="h-5 w-5" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5" />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Moon, Sun } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
// Check for stored preference or system preference
|
||||
const stored = localStorage.getItem('theme')
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
setTheme(stored)
|
||||
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme, mounted])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<button className="p-2 rounded-lg bg-secondary/50 text-muted-foreground">
|
||||
<Sun className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
onClick={toggleTheme}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="relative p-2 rounded-lg bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ rotate: theme === 'dark' ? 180 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="h-5 w-5" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5" />
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeToggle
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
|
|||
hint?: string
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, hint, id, ...props }, ref) => {
|
||||
const inputId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, hint, id, ...props }, ref) => {
|
||||
const generatedId = React.useId()
|
||||
const inputId = id ?? generatedId
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
|
|||
options: { value: string | number; label: string }[]
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, hint, id, options, ...props }, ref) => {
|
||||
const selectId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, hint, id, options, ...props }, ref) => {
|
||||
const generatedId = React.useId()
|
||||
const selectId = id ?? generatedId
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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*',
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /dashboard
|
||||
Disallow: /monitors
|
||||
Disallow: /settings
|
||||
Disallow: /admin
|
||||
Disallow: /api
|
||||
|
||||
Sitemap: https://sitechangemonitor.com/sitemap.xml
|
||||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/frontend
|
||||
/c/Users/timo/Documents/Websites/website-monitor/frontend
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites
|
||||
/c/Users/timo/Documents/Websites
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites
|
||||
/c/Users/timo/Documents/Websites
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
/c/Users/timo/Documents/Websites/website-monitor
|
||||
|
|
|
|||
Loading…
Reference in New Issue