This commit is contained in:
knuthtimo-lab 2026-01-21 08:21:19 +01:00
parent 4733e1a1cc
commit fd6e7c44e1
46 changed files with 3165 additions and 456 deletions

View File

@ -23,6 +23,7 @@
"nodemailer": "^6.9.8",
"openai": "^6.16.0",
"pg": "^8.11.3",
"serpapi": "^2.2.1",
"zod": "^3.22.4"
},
"devDependencies": {
@ -762,6 +763,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@ -3437,6 +3439,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
@ -3619,6 +3622,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -4018,6 +4022,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4920,6 +4925,7 @@
"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",
@ -7570,6 +7576,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.10.0",
"pg-pool": "^3.11.0",
@ -8203,6 +8210,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/serpapi": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serpapi/-/serpapi-2.2.1.tgz",
"integrity": "sha512-1HXXaIwDmYueFPauAggIkzozMi5P/a4/yRUxB8Z1kad0VQE/7ohf9a6xRQ99aXR252itDChfmJUfBdCA4phCYA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
@ -8701,6 +8714,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -8991,6 +9005,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -33,6 +33,7 @@
"nodemailer": "^6.9.8",
"openai": "^6.16.0",
"pg": "^8.11.3",
"serpapi": "^2.2.1",
"zod": "^3.22.4"
},
"devDependencies": {

View File

@ -0,0 +1,50 @@
import 'dotenv/config';
import { query } from '../src/db';
async function verifyWaitlistTable() {
try {
console.log('Verifying waitlist_leads table...');
const result = await query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'waitlist_leads'
);
`);
if (!result.rows[0].exists) {
console.log('Table waitlist_leads does not exist. Creating it...');
await query(`
CREATE TABLE IF NOT EXISTS waitlist_leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
source VARCHAR(50) DEFAULT 'landing_page',
referrer TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_email ON waitlist_leads(email);
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_created_at ON waitlist_leads(created_at);
`);
console.log('Table waitlist_leads created successfully.');
} else {
console.log('Table waitlist_leads already exists.');
// Check columns just in case
const columns = await query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'waitlist_leads';
`);
console.log('Columns:', columns.rows.map(r => r.column_name).join(', '));
}
console.log('Verification complete.');
} catch (error) {
console.error('Error verifying waitlist table:', error);
} finally {
process.exit(0);
}
}
verifyWaitlistTable();

View File

@ -0,0 +1,50 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
import path from 'path';
// Load env vars from .env file in backend root
dotenv.config({ path: path.join(__dirname, '../../.env') });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function fixSchema() {
console.log('🔧 Fixing schema...');
const client = await pool.connect();
try {
// Add seo_keywords column
console.log('Adding seo_keywords column...');
await client.query(`
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_keywords JSONB;
`);
// Create monitor_rankings table
console.log('Creating monitor_rankings table...');
await client.query(`
CREATE TABLE IF NOT EXISTS monitor_rankings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL,
rank INTEGER,
url_found TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
// Create indexes for monitor_rankings
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_monitor_id ON monitor_rankings(monitor_id);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_keyword ON monitor_rankings(keyword);`);
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_created_at ON monitor_rankings(created_at);`);
console.log('✅ Schema fixed successfully!');
} catch (err) {
console.error('❌ Schema fix failed:', err);
} finally {
client.release();
await pool.end();
}
}
fixSchema();

View File

@ -13,7 +13,7 @@ function toCamelCase<T>(obj: any): T {
let value = obj[key];
// Parse JSON fields that are stored as strings in the database
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
if ((key === 'ignore_rules' || key === 'keyword_rules' || key === 'seo_keywords') && typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
@ -164,8 +164,8 @@ export const db = {
const result = await query(
`INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
ignore_rules, keyword_rules, seo_keywords, seo_interval
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[
data.userId,
data.url,
@ -175,6 +175,8 @@ export const db = {
data.elementSelector || null,
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
data.seoKeywords ? JSON.stringify(data.seoKeywords) : null,
data.seoInterval || 'off',
]
);
return toCamelCase<Monitor>(result.rows[0]);
@ -220,9 +222,12 @@ export const db = {
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (key === 'ignoreRules' || key === 'keywordRules') {
if (key === 'ignoreRules' || key === 'keywordRules' || key === 'seoKeywords') {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(JSON.stringify(value));
} else if (key === 'seoInterval') {
fields.push(`seo_interval = $${paramCount}`);
values.push(value);
} else {
fields.push(`${snakeKey} = $${paramCount}`);
values.push(value);
@ -433,6 +438,47 @@ export const db = {
return result.rows.map(row => toCamelCase<any>(row));
},
},
rankings: {
async create(data: {
monitorId: string;
keyword: string;
rank: number | null;
urlFound: string | null;
}): Promise<any> {
const result = await query(
`INSERT INTO monitor_rankings (
monitor_id, keyword, rank, url_found
) VALUES ($1, $2, $3, $4) RETURNING *`,
[data.monitorId, data.keyword, data.rank, data.urlFound]
);
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
WHERE monitor_id = $1
ORDER BY keyword, created_at DESC`,
[monitorId]
);
return result.rows.map(row => toCamelCase<any>(row));
},
async findHistoryByMonitorId(monitorId: string, limit = 100): Promise<any[]> {
const result = await query(
`SELECT * FROM monitor_rankings
WHERE monitor_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[monitorId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
}
},
};
export default db;

View File

@ -0,0 +1,2 @@
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_interval VARCHAR(20) DEFAULT 'off';
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS last_seo_check_at TIMESTAMP;

View File

@ -0,0 +1,34 @@
import { Pool } from 'pg';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function runManualMigration() {
console.log('🔄 Running manual migration 006...');
try {
const client = await pool.connect();
try {
const sqlPath = path.join(__dirname, 'migrations/006_add_seo_scheduling.sql');
const sql = fs.readFileSync(sqlPath, 'utf-8');
console.log('📝 Executing SQL:', sql);
await client.query(sql);
console.log('✅ Migration 006 applied successfully!');
} finally {
client.release();
}
} catch (error) {
console.error('❌ Migration 006 failed:', error);
process.exit(1);
} finally {
await pool.end();
}
}
runManualMigration();

View File

@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_plan ON users(plan);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_plan ON users(plan);
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
@ -42,9 +42,9 @@ CREATE TABLE IF NOT EXISTS monitors (
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_monitors_user_id ON monitors(user_id);
CREATE INDEX idx_monitors_status ON monitors(status);
CREATE INDEX idx_monitors_last_checked_at ON monitors(last_checked_at);
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors(user_id);
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors(status);
CREATE INDEX IF NOT EXISTS idx_monitors_last_checked_at ON monitors(last_checked_at);
-- Snapshots table
CREATE TABLE IF NOT EXISTS snapshots (
@ -62,9 +62,9 @@ CREATE TABLE IF NOT EXISTS snapshots (
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_snapshots_monitor_id ON snapshots(monitor_id);
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
CREATE INDEX idx_snapshots_changed ON snapshots(changed);
CREATE INDEX IF NOT EXISTS idx_snapshots_monitor_id ON snapshots(monitor_id);
CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON snapshots(created_at);
CREATE INDEX IF NOT EXISTS idx_snapshots_changed ON snapshots(changed);
-- Alerts table
CREATE TABLE IF NOT EXISTS alerts (
@ -81,10 +81,10 @@ CREATE TABLE IF NOT EXISTS alerts (
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_alerts_user_id ON alerts(user_id);
CREATE INDEX idx_alerts_monitor_id ON alerts(monitor_id);
CREATE INDEX idx_alerts_created_at ON alerts(created_at);
CREATE INDEX idx_alerts_read_at ON alerts(read_at);
CREATE INDEX IF NOT EXISTS idx_alerts_user_id ON alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_alerts_monitor_id ON alerts(monitor_id);
CREATE INDEX IF NOT EXISTS idx_alerts_created_at ON alerts(created_at);
CREATE INDEX IF NOT EXISTS idx_alerts_read_at ON alerts(read_at);
-- Update timestamps trigger
CREATE OR REPLACE FUNCTION update_updated_at_column()
@ -132,4 +132,20 @@ CREATE TABLE IF NOT EXISTS waitlist_leads (
);
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_email ON waitlist_leads(email);
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_created_at ON waitlist_leads(created_at);
-- SEO Rankings table
CREATE TABLE IF NOT EXISTS monitor_rankings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL,
rank INTEGER, -- Null if not found in top 100
url_found TEXT, -- The specific URL that ranked
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_monitor_id ON monitor_rankings(monitor_id);
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_keyword ON monitor_rankings(keyword);
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_created_at ON monitor_rankings(created_at);
-- Add seo_keywords to monitors if it doesn't exist
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_keywords JSONB;

View File

@ -31,6 +31,8 @@ const createMonitorSchema = z.object({
})
)
.optional(),
seoKeywords: z.array(z.string()).optional(),
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
});
const updateMonitorSchema = z.object({
@ -56,6 +58,8 @@ const updateMonitorSchema = z.object({
})
)
.optional(),
seoKeywords: z.array(z.string()).optional(),
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
});
// Get plan limits
@ -92,17 +96,21 @@ router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
const monitors = await db.monitors.findByUserId(req.user.userId);
// Attach recent snapshots to each monitor for sparklines
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => {
// Attach recent snapshots and latest rankings to each monitor
const monitorsWithData = await Promise.all(monitors.map(async (monitor) => {
// Get last 20 snapshots for sparkline
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
// Get latest rankings
const latestRankings = await db.rankings.findLatestByMonitorId(monitor.id);
return {
...monitor,
recentSnapshots
recentSnapshots,
latestRankings
};
}));
res.json({ monitors: monitorsWithSnapshots });
res.json({ monitors: monitorsWithData });
} catch (error) {
console.error('List monitors error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
@ -180,6 +188,7 @@ router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
elementSelector: input.elementSelector,
ignoreRules: input.ignoreRules,
keywordRules: input.keywordRules,
seoKeywords: input.seoKeywords,
});
// Schedule recurring checks
@ -354,7 +363,10 @@ router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response):
// Await the check so user gets immediate feedback
try {
await checkMonitor(monitor.id);
const { type = 'all' } = req.body;
const checkType = ['all', 'content', 'seo'].includes(type) ? type : 'all';
await checkMonitor(monitor.id, checkType === 'seo' || checkType === 'all', checkType as any);
// Get the latest snapshot to return to the user
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
@ -453,6 +465,36 @@ router.get(
}
);
// Get monitor ranking history
router.get('/:id/rankings', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const monitor = await db.monitors.findById(req.params.id);
if (!monitor) {
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
return;
}
if (monitor.userId !== req.user.userId) {
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
return;
}
const history = await db.rankings.findHistoryByMonitorId(req.params.id, 100);
const latest = await db.rankings.findLatestByMonitorId(req.params.id);
res.json({ history, latest });
} catch (error) {
console.error('Get rankings error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to get rankings' });
}
});
// Export monitor audit trail (JSON or CSV)
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
try {

View File

@ -5,40 +5,120 @@ const router = Router();
router.get('/dynamic', (_req, res) => {
const now = new Date();
const timeString = now.toLocaleTimeString();
const randomValue = Math.floor(Math.random() * 1000);
// Toggle status based on seconds (even/odd) to guarantee change
const isNormal = now.getSeconds() % 2 === 0;
const statusMessage = isNormal
? "System Status: NORMAL - Everything is running smoothly."
: "System Status: WARNING - High load detected on server node!";
const statusColor = isNormal ? "green" : "red";
const minute = now.getMinutes();
const second = now.getSeconds();
const tenSecondBlock = Math.floor(second / 10);
// Dynamic Pricing Logic - changes every 10 seconds
const basicPrice = 9 + (tenSecondBlock % 5);
const proPrice = 29 + (tenSecondBlock % 10);
const enterprisePrice = 99 + (tenSecondBlock % 20);
// Dynamic Features
const features = [
"Unlimited Projects",
`Up to ${10 + (tenSecondBlock % 5)} team members`,
"Advanced Analytics",
tenSecondBlock % 2 === 0 ? "Priority Support" : "24/7 Live Chat Support",
second % 2 === 0 ? "Real-time Monitoring" : "Custom Webhooks"
];
// Dynamic Blog Posts
const blogPosts = [
{
id: 1,
title: tenSecondBlock % 3 === 0 ? "Scaling your SaaS in 2026" : "Growth Strategies for Modern Apps",
author: "Jane Doe",
date: "Jan 15, 2026"
},
{
id: 2,
title: "UI/UX Best Practices",
author: second % 20 > 10 ? "John Smith" : "Alex Rivera",
date: `Jan ${10 + (tenSecondBlock % 10)}, 2026`
}
];
const html = `
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>Dynamic Test Page</title>
<meta charset="UTF-8">
<title>CloudScale SaaS - Infrastructure for Growth</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.time { font-size: 2em; color: #0066cc; }
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; }
body { font-family: 'Inter', -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 1000px; margin: 0 auto; padding: 40px; }
header { text-align: center; margin-bottom: 50px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 40px 0; }
.card { border: 1px solid #eee; border-radius: 12px; padding: 25px; text-align: center; transition: transform 0.2s; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.card.featured { border: 2px solid #3b82f6; background-color: #eff6ff; transform: scale(1.05); }
.price { font-size: 3rem; font-weight: 800; margin: 20px 0; color: #111; }
.price span { font-size: 1rem; color: #666; font-weight: normal; }
ul { list-style: none; padding: 0; margin: 30px 0; text-align: left; }
li { margin-bottom: 12px; display: flex; align-items: center; }
li::before { content: "✓"; color: #10b981; font-weight: bold; margin-right: 10px; }
.badge { background: #dcfce7; color: #166534; padding: 4px 12px; border-radius: 99px; font-size: 0.8rem; font-weight: 600; }
.blog-section { margin-top: 60px; border-top: 1px solid #eee; padding-top: 40px; }
.blog-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
.post { background: #fafafa; padding: 20px; border-radius: 8px; }
.meta { color: #888; font-size: 0.9rem; margin-bottom: 10px; }
.footer { margin-top: 80px; text-align: center; color: #999; font-size: 0.9rem; }
</style>
</head>
<body>
<h1>Website Monitor Test</h1>
<div class="status">${statusMessage}</div>
<div class="content">
<p>Current Time: <span class="time">${timeString}</span></p>
<p>Random Value: <span class="random">${randomValue}</span></p>
<p>This page content flips every second to simulate a real website change.</p>
<div style="background: #f0f9ff; padding: 15px; margin-top: 20px; border-left: 4px solid #0066cc;">
<h3>New Feature Update</h3>
<p>We have deployed a new importance scoring update!</p>
<header>
<h1>CloudScale <span class="badge">v2.4 Updated</span></h1>
<p>Reliable infrastructure that scales with your business needs.</p>
<p style="color: #6366f1; font-weight: 500;">Current Update: ${now.toLocaleTimeString()}</p>
</header>
<div class="pricing-section">
<h2 style="text-align: center">Simple, Transparent Pricing</h2>
<div class="grid">
<div class="card">
<h3>Basic</h3>
<div class="price">$${basicPrice}<span>/mo</span></div>
<ul>
<li>5 Projects</li>
<li>Basic Analytics</li>
<li>Community Support</li>
</ul>
</div>
<div class="card featured">
<h3>Pro</h3>
<div class="price">$${proPrice}<span>/mo</span></div>
<ul>
${features.map(f => `<li>${f}</li>`).join('')}
</ul>
</div>
<div class="card">
<h3>Enterprise</h3>
<div class="price">$${enterprisePrice}<span>/mo</span></div>
<ul>
<li>Everything in Pro</li>
<li>Custom SLAs</li>
<li>Dedicated Account Manager</li>
<li>White-label Branding</li>
</ul>
</div>
</div>
</div>
<section class="blog-section">
<h2>From Our Blog</h2>
<div class="blog-grid">
${blogPosts.map(p => `
<div class="post">
<div class="meta">By ${p.author} ${p.date}</div>
<h3>${p.title}</h3>
<p>Discover how the latest trends in technology are shaping the future of digital products...</p>
</div>
`).join('')}
</div>
</section>
<div class="footer">
<p>&copy; 2026 CloudScale Platform. Serving ${1000 + (minute * 10)} active customers worldwide.</p>
</div>
</body>
</html>
`;

View File

@ -20,10 +20,21 @@ router.post('/meta-preview', async (req, res) => {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; WebsiteMonitorBot/1.0; +https://websitemonitor.com)'
'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: 5000,
validateStatus: (status) => status < 500 // Resolve even if 404/403 to avoid crashing flow immediately
timeout: 30000,
httpAgent: new (require('http').Agent)({ family: 4, keepAlive: true }),
httpsAgent: new (require('https').Agent)({ family: 4, rejectUnauthorized: false, keepAlive: true }),
validateStatus: (status) => status < 500
});
const html = response.data;

View File

@ -1,5 +1,5 @@
import { Router } from 'express';
import { pool } from '../db';
import { query } from '../db';
import { z } from 'zod';
const router = Router();
@ -17,16 +17,16 @@ router.post('/', async (req, res) => {
const data = waitlistSchema.parse(req.body);
// Check if email already exists
const existing = await pool.query(
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 pool.query('SELECT COUNT(*) FROM waitlist_leads');
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
const position = parseInt(countResult.rows[0].count, 10);
return res.json({
success: true,
message: 'You\'re on the list!',
@ -36,13 +36,13 @@ router.post('/', async (req, res) => {
}
// Insert new lead
await pool.query(
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 pool.query('SELECT COUNT(*) FROM waitlist_leads');
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})`);
@ -73,12 +73,12 @@ router.post('/', async (req, res) => {
// GET /api/waitlist/count - Get current waitlist count (public)
router.get('/count', async (_req, res) => {
try {
const result = await pool.query('SELECT COUNT(*) FROM waitlist_leads');
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,
@ -92,4 +92,40 @@ router.get('/count', async (_req, res) => {
}
});
// 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({
success: false,
message: 'Unauthorized',
});
}
// 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;

View File

@ -10,14 +10,19 @@ import {
import { calculateChangeImportance } from './importance';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
import { generateSimpleSummary, generateAISummary } from './summarizer';
import { processSeoChecks } from './seo';
export interface CheckResult {
snapshot: Snapshot;
alertSent: boolean;
}
export async function checkMonitor(monitorId: string): Promise<CheckResult | void> {
console.log(`[Monitor] Checking monitor ${monitorId}`);
export async function checkMonitor(
monitorId: string,
forceSeo = false,
checkType: 'all' | 'content' | 'seo' = 'all'
): Promise<{ snapshot?: Snapshot; alertSent: boolean } | void> {
console.log(`[Monitor] Starting check: ${monitorId} | Type: ${checkType} | ForceSEO: ${forceSeo}`);
try {
const monitor = await db.monitors.findById(monitorId);
@ -28,184 +33,217 @@ export async function checkMonitor(monitorId: string): Promise<CheckResult | voi
}
if (monitor.status !== 'active' && monitor.status !== 'error') {
console.log(`[Monitor] Monitor ${monitorId} is not active or error, skipping (status: ${monitor.status})`);
console.log(`[Monitor] Monitor ${monitorId} skipping (status: ${monitor.status})`);
return;
}
// Fetch page with retries
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
let snapshot: Snapshot | undefined;
let changed = false;
// Retry on failure (max 3 attempts)
if (fetchResult.error) {
console.log(`[Monitor] Fetch failed, retrying... (1/3)`);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
// Content Check Part
if (checkType === 'all' || checkType === 'content') {
console.log(`[Monitor] Running CONTENT check for ${monitor.name} (${monitor.url})`);
// Fetch page with retries
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
// Retry on failure (max 3 attempts)
if (fetchResult.error) {
console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
console.log(`[Monitor] Fetch failed, retrying... (1/3)`);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
}
}
// Check for HTTP error status
if (!fetchResult.error && fetchResult.status >= 400) {
fetchResult.error = `HTTP ${fetchResult.status}`;
}
// If still failing after retries
if (fetchResult.error) {
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
// Create error snapshot
const failedSnapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed: false,
errorMessage: fetchResult.error,
});
await db.monitors.incrementErrors(monitor.id);
// Send error alert if consecutive errors > 3
if (monitor.consecutiveErrors >= 2) {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendErrorAlert(monitor, user, fetchResult.error);
if (fetchResult.error) {
console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
}
}
// Check for HTTP error status
if (!fetchResult.error && fetchResult.status >= 400) {
fetchResult.error = `HTTP ${fetchResult.status}`;
}
return { snapshot: failedSnapshot, alertSent: false };
}
// If still failing after retries
if (fetchResult.error) {
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
// Apply noise filters
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules));
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
// Create error snapshot
const failedSnapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed: false,
errorMessage: fetchResult.error,
});
// Get previous snapshot
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
await db.monitors.incrementErrors(monitor.id);
let changed = false;
let changePercentage = 0;
let diffResult: ReturnType<typeof compareDiffs> | undefined;
// Send error alert if consecutive errors > 3
if (monitor.consecutiveErrors >= 2) {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendErrorAlert(monitor, user, fetchResult.error);
}
}
if (previousSnapshot) {
// Apply same filters to previous content for fair comparison
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
return { snapshot: failedSnapshot, alertSent: false };
}
// Compare
diffResult = compareDiffs(previousHtml, processedHtml);
changed = diffResult.changed;
changePercentage = diffResult.changePercentage;
// Apply noise filters
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
console.log(
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
);
// Get previous snapshot
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
// Check keywords
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
const keywordMatches = checkKeywords(
previousHtml,
processedHtml,
monitor.keywordRules
let changePercentage = 0;
let diffResult: ReturnType<typeof compareDiffs> | undefined;
if (previousSnapshot) {
// Apply same filters to previous content for fair comparison
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
// Compare
diffResult = compareDiffs(previousHtml, processedHtml);
changed = diffResult.changed;
changePercentage = diffResult.changePercentage;
console.log(
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
);
if (keywordMatches.length > 0) {
console.log(`[Monitor] Keyword matches found:`, keywordMatches);
const user = await db.users.findById(monitor.userId);
// Check keywords
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
const keywordMatches = checkKeywords(
previousHtml,
processedHtml,
monitor.keywordRules
);
if (user) {
for (const match of keywordMatches) {
await sendKeywordAlert(monitor, user, match);
if (keywordMatches.length > 0) {
console.log(`[Monitor] Keyword matches found:`, keywordMatches);
const user = await db.users.findById(monitor.userId);
if (user) {
for (const match of keywordMatches) {
await sendKeywordAlert(monitor, user, match);
}
}
}
}
} else {
// First check - consider it as "changed" to create baseline
changed = true;
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
}
} else {
// First check - consider it as "changed" to create baseline
changed = true;
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
}
// Generate human-readable summary (Hybrid approach)
let summary: string | undefined;
// Generate human-readable summary (Hybrid approach)
let summary: string | undefined;
if (changed && previousSnapshot && diffResult) {
// Hybrid logic: AI for changes (≥5%), simple for very small changes
if (changePercentage >= 5) {
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
try {
summary = await generateAISummary(diffResult.diff, changePercentage);
} catch (error) {
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
if (changed && previousSnapshot && diffResult) {
// Hybrid logic: AI for changes (≥5%), simple for very small changes
if (changePercentage >= 5) {
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
try {
summary = await generateAISummary(diffResult.diff, changePercentage, monitor.url);
} catch (error) {
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
} else {
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
} else {
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
}
// Create snapshot
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: fetchResult.html,
textContent: fetchResult.text,
contentHash: fetchResult.hash,
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed,
changePercentage: changed ? changePercentage : undefined,
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
summary,
});
// Create snapshot
console.log(`[Monitor] Creating snapshot in DB for ${monitor.name}`);
snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: fetchResult.html,
textContent: fetchResult.text,
contentHash: fetchResult.hash,
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed,
changePercentage: changed ? changePercentage : undefined,
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
summary,
});
// Update monitor
await db.monitors.updateLastChecked(monitor.id, changed);
// Update monitor
await db.monitors.updateLastChecked(monitor.id, changed);
// Send alert if changed and not first check
if (changed && previousSnapshot) {
try {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
// Send alert if changed and not first check
if (changed && previousSnapshot) {
try {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
}
} catch (alertError) {
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
}
} catch (alertError) {
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
// Continue execution - do not fail the check
}
}
// Clean up old snapshots based on user plan retention period
try {
const retentionUser = await db.users.findById(monitor.userId);
if (retentionUser) {
const { getRetentionDays } = await import('../config');
const retentionDays = getRetentionDays(retentionUser.plan);
await db.snapshots.deleteOldSnapshotsByAge(monitor.id, retentionDays);
// SEO Check Part
if ((checkType === 'all' || checkType === 'seo') && monitor.seoKeywords && monitor.seoKeywords.length > 0) {
let shouldRunSeo = false;
if (forceSeo) {
console.log(`[Monitor] SEO check triggered manually for ${monitor.name}`);
shouldRunSeo = true;
} else if (monitor.seoInterval && monitor.seoInterval !== 'off') {
if (!monitor.lastSeoCheckAt) {
shouldRunSeo = true;
} else {
const hoursSinceLast = (Date.now() - new Date(monitor.lastSeoCheckAt).getTime()) / (1000 * 60 * 60);
switch (monitor.seoInterval) {
case 'daily': shouldRunSeo = hoursSinceLast >= 24; break;
case '2d': shouldRunSeo = hoursSinceLast >= 48; break;
case 'weekly': shouldRunSeo = hoursSinceLast >= 168; break;
case 'monthly': shouldRunSeo = hoursSinceLast >= 720; break;
}
}
}
if (shouldRunSeo) {
console.log(`[Monitor] Running SEO check for ${monitor.name} (Schedule: ${monitor.seoInterval})`);
// Update last_seo_check_at immediately to prevent double scheduling if slow
await db.monitors.update(monitor.id, { lastSeoCheckAt: new Date() });
if (forceSeo) {
// Await SEO check if explicitly forced (manual trigger)
await processSeoChecks(monitor.id, monitor.url, monitor.seoKeywords);
} else {
// Run in background for scheduled checks
processSeoChecks(monitor.id, monitor.url, monitor.seoKeywords)
.catch(err => console.error(`[Monitor] SEO check failed for ${monitor.name}:`, err));
}
}
} catch (cleanupError) {
console.error(`[Monitor] Failed to cleanup snapshots for ${monitor.id}:`, cleanupError);
}
console.log(`[Monitor] Check completed for ${monitor.name}`);
return { snapshot, alertSent: changed && !!previousSnapshot };
console.log(`[Monitor] Check completed for ${monitor.name} (Snapshot created: ${!!snapshot})`);
return {
snapshot,
alertSent: changed
};
} catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId);

View File

@ -0,0 +1,98 @@
import { getJson } from 'serpapi';
import db from '../db';
interface RankingResult {
rank: number | null;
foundUrl: string | null;
error?: string;
}
export async function checkRanking(keyword: string, targetUrl: string): Promise<RankingResult> {
const apiKey = process.env.SERPAPI_KEY;
if (!apiKey) {
console.error('SERPAPI_KEY is missing');
return { rank: null, foundUrl: null, error: 'SERPAPI_KEY missing' };
}
// Domain normalization for easier matching
// Removes protocol, www, and trailing slashes
const normalizeUrl = (url: string) => {
try {
const u = new URL(url.startsWith('http') ? url : `https://${url}`);
return u.hostname.replace('www.', '') + u.pathname.replace(/\/$/, '');
} catch (e) {
return url.replace('www.', '').replace(/\/$/, '');
}
};
const normalizedTarget = normalizeUrl(targetUrl);
return new Promise((resolve) => {
try {
getJson(
{
engine: 'google',
q: keyword,
api_key: apiKey,
num: 100, // Check top 100 results
},
(json: any) => {
if (json.error) {
console.error('SerpApi error:', json.error);
resolve({ rank: null, foundUrl: null, error: json.error });
return;
}
if (!json.organic_results) {
resolve({ rank: null, foundUrl: null });
return;
}
for (const result of json.organic_results) {
const resultUrl = result.link;
const normalizedResult = normalizeUrl(resultUrl);
// Check if result contains our target domain
if (normalizedResult.includes(normalizedTarget) || normalizedTarget.includes(normalizedResult)) {
resolve({
rank: result.position,
foundUrl: resultUrl,
});
return;
}
}
resolve({ rank: null, foundUrl: null });
}
);
} catch (error: any) {
console.error('SeoService error:', error);
resolve({ rank: null, foundUrl: null, error: error.message });
}
});
}
export async function processSeoChecks(monitorId: string, url: string, keywords: string[]) {
if (!keywords || keywords.length === 0) return;
console.log(`[SEO] Starting checks for monitor ${monitorId} (${keywords.length} keywords)`);
for (const keyword of keywords) {
const result = await checkRanking(keyword, url);
if (result.rank !== null || result.foundUrl !== null) {
console.log(`[SEO] Found rank ${result.rank} for "${keyword}"`);
} else {
console.log(`[SEO] Not found in top 100 for "${keyword}"`);
}
// Save to DB
await db.rankings.create({
monitorId,
keyword,
rank: result.rank,
urlFound: result.foundUrl
});
}
}

View File

@ -76,11 +76,11 @@ export function generateSimpleSummary(
// Add text preview if available
if (textPreviews.length > 0) {
const previewText = textPreviews.slice(0, 2).join(' → ');
const previewText = textPreviews.join(' | ');
if (summary) {
summary += `. Changed: "${previewText}"`;
summary += `. Details: ${previewText}`;
} else {
summary = `Text changed: "${previewText}"`;
summary = `Changes: ${previewText}`;
}
}
@ -213,7 +213,8 @@ function countRemovedElements(htmlOld: string, htmlNew: string, tag: string): nu
*/
export async function generateAISummary(
diff: Change[],
changePercentage: number
changePercentage: number,
url?: string
): Promise<string> {
try {
// Check if API key is configured
@ -229,17 +230,30 @@ export async function generateAISummary(
// Format diff for AI (reduce token count)
const formattedDiff = formatDiffForAI(diff);
const prompt = `Analyze this website change and create a concise summary for non-programmers.
Focus on IMPORTANT changes only. Medium detail level.
const prompt = `Analyze the website changes for: ${url || 'unknown'}
You are an expert content monitor. Your task is to provide a high-quality, professional summary of what changed on this page.
Change percentage: ${changePercentage.toFixed(2)}%
GOAL:
Categorize changes by page section and describe their impact on the user.
Diff:
CRITICAL INSTRUCTIONS:
1. Identify the SECTION: Look at the tags and context. Is it the "Pricing Table", "Feature List", "Hero Section", "Blog Feed", or "Footer"? Mention it clearly.
2. Be SPECIFIC: Instead of "Pricing updated", say "The Pro Plan monthly price increased from $29 to $34".
3. CONTEXTUALIZE: Group related changes together. For example, "Updated the 'CloudScale v2.4' header and refreshed the blog post titles in the feed section."
4. NO JARGON: Avoid terms like "HTML", "div", "CSS", "selectors". Talk to the user, not a developer.
5. TONE: Professional, concise, and helpful.
Change magnitude: ${changePercentage.toFixed(2)}%
DIFF DATA TO ANALYZE:
${formattedDiff}
Format: "Section name: What changed. Details if important."
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer."
Keep it under 100 words. Be specific about what changed, not how.`;
FORMAT:
- Start with a single summary sentence.
- Use DOUBLE NEWLINES between different sections (e.g., between Pricing and Blog).
- Each bullet point MUST be on its own new line.
- Use bold headers for sections like **Pricing Table:** or **Header Update:**.
- Limit response to 150 words.`;
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini', // Fastest, cheapest

View File

@ -47,6 +47,9 @@ export interface Monitor {
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
seoKeywords?: string[];
seoInterval?: string;
lastSeoCheckAt?: Date;
lastCheckedAt?: Date;
lastChangedAt?: Date;
consecutiveErrors: number;
@ -98,6 +101,7 @@ export interface CreateMonitorInput {
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
seoInterval?: string;
}
export interface UpdateMonitorInput {
@ -107,4 +111,5 @@ export interface UpdateMonitorInput {
elementSelector?: string;
ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[];
seoInterval?: string;
}

213
frontend/app/admin/page.tsx Normal file
View File

@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Lock, Loader2, ArrowLeft, RefreshCw, Calendar, Mail, Globe } from 'lucide-react'
import { Button } from '@/components/ui/button'
import Link from 'next/link'
interface Lead {
id: string
email: string
source: string
referrer: string
created_at: string
}
export default function AdminPage() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [data, setData] = useState<{ total: number; leads: Lead[] } | null>(null)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
headers: {
'x-admin-password': password
}
})
const result = await response.json()
if (result.success) {
setIsAuthenticated(true)
setData(result)
} else {
setError('Invalid password')
}
} catch (err) {
setError('Connection error')
} finally {
setIsLoading(false)
}
}
const refreshData = async () => {
setIsLoading(true)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
headers: {
'x-admin-password': password
}
})
const result = await response.json()
if (result.success) setData(result)
} finally {
setIsLoading(false)
}
}
// LOGIN SCREEN
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-sm"
>
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors mb-6">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Website
</Link>
<h1 className="text-2xl font-bold text-foreground">Admin Access</h1>
<p className="text-muted-foreground mt-2">Enter password to view waitlist</p>
</div>
<div className="bg-card border border-border rounded-xl p-6 shadow-xl">
<form onSubmit={handleLogin} className="space-y-4">
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full h-10 pl-10 pr-4 rounded-lg border border-border bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="Password"
autoFocus
/>
</div>
{error && (
<div className="text-sm text-red-500 font-medium text-center">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Login'}
</Button>
</form>
</div>
</motion.div>
</div>
)
}
// DASHBOARD
return (
<div className="min-h-screen bg-secondary/10 p-6 md:p-12">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-foreground">Waitlist Dashboard</h1>
<p className="text-muted-foreground mt-1">Real-time stats and signups</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={refreshData} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="ghost" onClick={() => setIsAuthenticated(false)}>
Logout
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid md:grid-cols-3 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-card border border-border p-6 rounded-xl shadow-sm"
>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Total Signups</h3>
<div className="mt-2 text-4xl font-bold text-[hsl(var(--primary))]">
{data?.total || 0}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-card border border-border p-6 rounded-xl shadow-sm"
>
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Latest Signup</h3>
<div className="mt-2 text-lg font-medium text-foreground truncate">
{data?.leads[0]?.email || 'N/A'}
</div>
<div className="text-xs text-muted-foreground mt-1">
{data?.leads[0] ? new Date(data.leads[0].created_at).toLocaleString() : '-'}
</div>
</motion.div>
</div>
{/* Listings Table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-card border border-border rounded-xl shadow-sm overflow-hidden"
>
<div className="px-6 py-4 border-b border-border bg-secondary/5">
<h3 className="font-semibold text-foreground">Recent Signups</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-secondary/20 text-muted-foreground font-medium">
<tr>
<th className="px-6 py-3">Email</th>
<th className="px-6 py-3">Source</th>
<th className="px-6 py-3">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{data?.leads.map((lead) => (
<tr key={lead.id} className="hover:bg-secondary/5 transition-colors">
<td className="px-6 py-4 font-medium text-foreground flex items-center gap-2">
<Mail className="h-3 w-3 text-muted-foreground" />
{lead.email}
</td>
<td className="px-6 py-4 text-muted-foreground">
{lead.source}
{lead.referrer && <span className="text-xs ml-2 opacity-70">via {new URL(lead.referrer).hostname}</span>}
</td>
<td className="px-6 py-4 text-muted-foreground whitespace-nowrap">
{new Date(lead.created_at).toLocaleString()}
</td>
</tr>
))}
{data?.leads.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-8 text-center text-muted-foreground">
No signups yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.div>
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export default function BlogPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-5xl space-y-12">
<div className="space-y-4">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground">Blog</h1>
<p className="text-xl text-muted-foreground max-w-2xl">
Latest updates, guides, and insights from the Alertify team.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Placeholder for empty state */}
<div className="col-span-full py-12 text-center border rounded-2xl border-dashed border-border bg-secondary/5">
<p className="text-muted-foreground">No posts published yet. Stay tuned!</p>
</div>
</div>
</div>
</div>
<Footer />
</div>
)
}

BIN
frontend/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

View File

@ -18,12 +18,15 @@ const spaceGrotesk = Space_Grotesk({
})
export const metadata: Metadata = {
title: 'Website Monitor - Track Changes on Any Website',
description: 'Monitor website changes with smart filtering and instant alerts',
title: 'Alertify - Track Changes on Any Website',
description: 'Alertify helps you track website changes in real-time. Get notified instantly when content updates.',
}
import { Toaster } from 'sonner'
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
import { CookieBanner } from '@/components/compliance/CookieBanner'
export default function RootLayout({
children,
}: {
@ -32,8 +35,11 @@ export default function RootLayout({
return (
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
<body className={interTight.className}>
<Providers>{children}</Providers>
<Toaster richColors position="top-right" />
<PostHogProvider>
<Providers>{children}</Providers>
<CookieBanner />
<Toaster richColors position="top-right" />
</PostHogProvider>
</body>
</html>
)

View File

@ -3,6 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
@ -40,30 +41,18 @@ export default function LoginPage() {
<div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<svg
className="h-7 w-7 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
fill
className="object-contain"
priority
/>
</div>
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Sign in to your Website Monitor account
Sign in to your Alertify account
</CardDescription>
</CardHeader>

View File

@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
@ -7,13 +8,17 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { SEORankingCard } from '@/components/seo-ranking-card'
import { toast } from 'sonner'
export default function MonitorHistoryPage() {
const router = useRouter()
const params = useParams()
const id = params?.id as string
const [isChecking, setIsChecking] = useState(false)
const [isCheckingSeo, setIsCheckingSeo] = useState(false)
const { data: monitorData } = useQuery({
const { data: monitorData, refetch: refetchMonitor } = useQuery({
queryKey: ['monitor', id],
queryFn: async () => {
const response = await monitorAPI.get(id)
@ -21,7 +26,7 @@ export default function MonitorHistoryPage() {
},
})
const { data: historyData, isLoading } = useQuery({
const { data: historyData, isLoading, refetch: refetchHistory } = useQuery({
queryKey: ['history', id],
queryFn: async () => {
const response = await monitorAPI.history(id)
@ -29,6 +34,41 @@ export default function MonitorHistoryPage() {
},
})
const handleCheckNow = async (type: 'content' | 'seo' = 'content') => {
if (type === 'seo') {
if (isCheckingSeo) return
setIsCheckingSeo(true)
} else {
if (isChecking) return
setIsChecking(true)
}
try {
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
} else {
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else {
toast.success(result.snapshot?.changed ? 'Changes detected!' : 'No changes detected')
}
}
refetchMonitor()
refetchHistory()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(`Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
if (type === 'seo') {
setIsCheckingSeo(false)
} else {
setIsChecking(false)
}
}
}
if (isLoading) {
return (
<DashboardLayout>
@ -66,6 +106,40 @@ export default function MonitorHistoryPage() {
</div>
{monitor && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow('content')}
disabled={isChecking || isCheckingSeo}
className="gap-2"
>
{isChecking ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
Check Now
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow('seo')}
disabled={isChecking || isCheckingSeo}
className="gap-2 border-purple-200 text-purple-700 hover:bg-purple-50"
>
{isCheckingSeo ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
)}
SEO Check
</Button>
)}
<Button
variant="outline"
size="sm"
@ -105,6 +179,19 @@ export default function MonitorHistoryPage() {
</div>
</div>
{/* SEO Rankings */}
{monitor && monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<div className="mb-8">
<div className="flex items-center gap-2 mb-4 text-purple-700">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<h2 className="text-lg font-semibold">SEO Keyword Performance</h2>
</div>
<SEORankingCard monitorId={id} keywords={monitor.seoKeywords} />
</div>
)}
{/* History List */}
<div>
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
@ -181,7 +268,7 @@ export default function MonitorHistoryPage() {
{snapshot.summary && (
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
<p className="font-medium text-foreground mb-1">Summary</p>
<p>{snapshot.summary}</p>
<p className="whitespace-pre-wrap break-words leading-relaxed mt-2">{snapshot.summary}</p>
</div>
)}
</div>

View File

@ -158,7 +158,7 @@ export default function SnapshotDetailsPage() {
{snapshot.summary && (
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
<p className="text-sm font-medium text-blue-900">Change Summary</p>
<p className="text-sm text-blue-700 mt-1">{snapshot.summary}</p>
<p className="text-sm text-blue-700 mt-2 whitespace-pre-wrap break-words leading-relaxed">{snapshot.summary}</p>
</div>
)}
</CardContent>

View File

@ -87,6 +87,7 @@ export default function MonitorsPage() {
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState<string | null>(null)
const [checkingSeoId, setCheckingSeoId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
@ -102,6 +103,8 @@ export default function MonitorsPage() {
threshold?: number
caseSensitive?: boolean
}>,
seoKeywords: [] as string[],
seoInterval: 'off',
})
const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
@ -131,6 +134,10 @@ export default function MonitorsPage() {
if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules
}
if (newMonitor.seoKeywords.length > 0) {
payload.seoKeywords = newMonitor.seoKeywords
payload.seoInterval = newMonitor.seoInterval
}
if (editingId) {
await monitorAPI.update(editingId, payload)
@ -146,7 +153,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
setShowAddForm(false)
setEditingId(null)
@ -179,7 +188,9 @@ export default function MonitorsPage() {
frequency: monitor.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitor.keywordRules || []
keywordRules: monitor.keywordRules || [],
seoKeywords: monitor.seoKeywords || [],
seoInterval: monitor.seoInterval || 'off',
})
setEditingId(monitor.id)
setShowAddForm(true)
@ -194,7 +205,9 @@ export default function MonitorsPage() {
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
}
@ -223,38 +236,55 @@ export default function MonitorsPage() {
frequency: monitorData.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitorData.keywordRules as any[]
keywordRules: monitorData.keywordRules as any[],
seoKeywords: [],
seoInterval: 'off',
})
setShowTemplates(false)
setShowAddForm(true)
}
const handleCheckNow = async (id: string) => {
// Prevent multiple simultaneous checks
if (checkingId !== null) return
const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
// Prevent multiple simultaneous checks of the same type
if (type === 'seo') {
if (checkingSeoId !== null) return
setCheckingSeoId(id)
} else {
if (checkingId !== null) return
setCheckingId(id)
}
setCheckingId(id)
try {
const result = await monitorAPI.check(id)
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
// For SEO check, we might want to refresh rankings specifically if we had a way
} else {
toast.info('No changes detected')
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
} else {
toast.info('No changes detected')
}
}
refetch()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(err.response?.data?.message || 'Failed to check monitor')
toast.error(err.response?.data?.message || `Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
setCheckingId(null)
if (type === 'seo') {
setCheckingSeoId(null)
} else {
setCheckingId(null)
}
}
}
@ -646,6 +676,80 @@ export default function MonitorsPage() {
)}
</div>
{/* SEO Keywords Section */}
<div className="space-y-3 rounded-lg border border-purple-500/20 bg-purple-500/5 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-sm">SEO Tracking</h4>
<p className="text-xs text-muted-foreground">Track Google ranking for specific keywords</p>
</div>
<div className="flex gap-2">
<Select
label=""
value={newMonitor.seoInterval}
onChange={(e) => setNewMonitor({ ...newMonitor, seoInterval: e.target.value })}
options={[
{ value: 'off', label: 'Manual Check Only' },
{ value: 'daily', label: 'Check Daily' },
{ value: '2d', label: 'Every 2 Days' },
{ value: 'weekly', label: 'Check Weekly' },
{ value: 'monthly', label: 'Check Monthly' }
]}
className="w-40"
/>
<Button
type="button"
size="sm"
variant="outline"
className="border-purple-200 hover:bg-purple-50 hover:text-purple-700"
onClick={() => {
setNewMonitor({
...newMonitor,
seoKeywords: [...newMonitor.seoKeywords, '']
})
}}
>
+ Add Keyword
</Button>
</div>
</div>
{newMonitor.seoKeywords.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No SEO keywords configured.</p>
) : (
<div className="space-y-2">
{newMonitor.seoKeywords.map((keyword, index) => (
<div key={index} className="flex gap-2">
<Input
type="text"
value={keyword}
onChange={(e) => {
const updated = [...newMonitor.seoKeywords]
updated[index] = e.target.value
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
placeholder="e.g. best coffee in austin"
className="flex-1"
/>
<button
type="button"
onClick={() => {
const updated = newMonitor.seoKeywords.filter((_, i) => i !== index)
setNewMonitor({ ...newMonitor, seoKeywords: updated })
}}
className="rounded p-2 text-red-500 hover:bg-red-50"
title="Remove"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">
{editingId ? 'Save Changes' : 'Create Monitor'}
@ -714,16 +818,41 @@ export default function MonitorsPage() {
</div>
{/* Stats Row */}
{/* SEO Status */}
{monitor.seoInterval && monitor.seoInterval !== 'off' && (
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-purple-50 p-3 text-center text-xs border border-purple-100">
<div>
<p className="font-semibold text-purple-700">{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}</p>
<p className="text-purple-600/80">SEO Check</p>
</div>
<div>
{monitor.lastSeoCheckAt ? (
<>
<p className="font-semibold text-purple-700">
{new Date(monitor.lastSeoCheckAt).toLocaleDateString()}
</p>
<p className="text-purple-600/80">Last SEO</p>
</>
) : (
<>
<p className="font-semibold text-purple-700">-</p>
<p className="text-purple-600/80">Last SEO</p>
</>
)}
</div>
</div>
)}
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
<div>
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
<p className="text-muted-foreground">Frequency</p>
</div>
<div>
{monitor.last_changed_at ? (
{monitor.lastChangedAt ? (
<>
<p className="font-semibold text-foreground">
{new Date(monitor.last_changed_at).toLocaleDateString()}
{new Date(monitor.lastChangedAt).toLocaleDateString()}
</p>
<p className="text-muted-foreground">Last Change</p>
</>
@ -737,9 +866,9 @@ export default function MonitorsPage() {
</div>
{/* Last Checked */}
{monitor.last_checked_at ? (
{monitor.lastCheckedAt ? (
<p className="mb-4 text-xs text-muted-foreground">
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}
</p>
) : (
<p className="mb-4 text-xs text-muted-foreground">
@ -747,9 +876,26 @@ export default function MonitorsPage() {
</p>
)}
{/* SEO Rankings */}
{monitor.latestRankings && monitor.latestRankings.length > 0 && (
<div className="mb-4 space-y-1">
<p className="text-[10px] font-medium text-purple-600 uppercase tracking-wider">Top Rankings</p>
<div className="grid grid-cols-1 gap-1">
{monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => (
<div key={idx} className="flex items-center justify-between text-[11px] bg-purple-50/50 rounded px-2 py-1 border border-purple-100/50">
<span className="truncate max-w-[140px] text-purple-900 font-medium">{r.keyword}</span>
<Badge variant="outline" className="bg-white border-purple-200 text-purple-700 h-4 px-1 text-[9px] leading-none min-w-[30px] justify-center">
#{r.rank || '100+'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-primary/40 pl-2 line-clamp-2">
"{monitor.recentSnapshots[0].summary}"
</p>
)}
@ -784,12 +930,24 @@ export default function MonitorsPage() {
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
>
{checkingSeoId === monitor.id ? 'SEO Checking...' : 'Check SEO'}
</Button>
)}
<Button
variant="ghost"
size="sm"
@ -887,7 +1045,7 @@ export default function MonitorsPage() {
)}
<div className="text-center">
<p className="font-medium text-foreground">
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
{monitor.lastChangedAt ? new Date(monitor.lastChangedAt).toLocaleDateString() : '-'}
</p>
<p className="text-xs">Last Change</p>
</div>
@ -898,12 +1056,25 @@ export default function MonitorsPage() {
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow(monitor.id)}
onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
<Button
variant="outline"
size="sm"
className="border-purple-200 text-purple-700 hover:bg-purple-50"
onClick={() => handleCheckNow(monitor.id, 'seo')}
loading={checkingSeoId === monitor.id}
disabled={checkingSeoId !== null}
title="Check SEO Rankings"
>
{checkingSeoId === monitor.id ? 'Checking SEO...' : 'SEO'}
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
Edit
</Button>

View File

@ -3,10 +3,12 @@
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { HeroSection } from '@/components/landing/LandingSections'
import { motion, AnimatePresence } from 'framer-motion'
import { Footer } from '@/components/layout/Footer'
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
// Dynamic imports for performance optimization (lazy loading)
@ -78,10 +80,10 @@ export default function Home() {
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 transition-all duration-200">
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2 group">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary transition-transform group-hover:scale-110 shadow-lg shadow-primary/20">
<Monitor className="h-5 w-5 text-primary-foreground" />
<div className="relative h-8 w-8 transition-transform group-hover:scale-110">
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
</div>
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
<span className="text-lg font-bold tracking-tight text-foreground">Alertify</span>
</Link>
<nav className="hidden items-center gap-6 md:flex">
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
@ -202,67 +204,7 @@ export default function Home() {
<FinalCTA />
{/* Footer */}
< footer className="border-t border-border bg-background py-12 text-sm" >
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
<div className="md:col-span-2">
<div className="mb-6 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Monitor className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-lg font-bold text-foreground">MonitorTool</span>
</div>
<p className="text-muted-foreground max-w-xs mb-6">
The modern platform for uptime monitoring, change detection, and performance tracking.
</p>
<div className="flex gap-4">
{/* Social icons placeholders */}
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
<Globe className="h-4 w-4" />
</div>
</div>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="#features" className="hover:text-primary transition-colors">Features</Link></li>
<li><Link href="#use-cases" className="hover:text-primary transition-colors">Use Cases</Link></li>
</ul>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="#" className="hover:text-primary transition-colors">About</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Blog</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Careers</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Contact</Link></li>
</ul>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="#" className="hover:text-primary transition-colors">Privacy</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Terms</Link></li>
<li><Link href="#" className="hover:text-primary transition-colors">Cookie Policy</Link></li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
<p>© 2026 MonitorTool. All rights reserved.</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
System Operational
</div>
</div>
</div>
</footer >
<Footer />
</div >
)
}

View File

@ -0,0 +1,61 @@
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { Footer } from '@/components/layout/Footer'
export default function PrivacyPage() {
return (
<div className="min-h-screen bg-background flex flex-col">
<div className="flex-1 py-24 px-6">
<div className="mx-auto max-w-3xl space-y-8">
<div className="space-y-4">
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Home
</Link>
<h1 className="text-4xl font-bold font-display text-foreground">Privacy Policy</h1>
<p className="text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p>
</div>
<section className="space-y-4 prose prose-neutral dark:prose-invert max-w-none">
<h3>1. Introduction</h3>
<p>
Welcome to Alertify. We respect your privacy and are committed to protecting your personal data.
This privacy policy will inform you as to how we look after your personal data when you visit our website
and tell you about your privacy rights and how the law protects you.
</p>
<h3>2. Data We Collect</h3>
<p>
We may collect, use, store and transfer different kinds of personal data about you which we have grouped together follows:
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li>Identity Data: includes email address.</li>
<li>Technical Data: includes internet protocol (IP) address, browser type and version, time zone setting and location.</li>
<li>Usage Data: includes information about how you use our website and services.</li>
</ul>
<h3>3. How We Use Your Data</h3>
<p>
We will only use your personal data when the law allows us to. Most commonly, we will use your personal data in the following circumstances:
</p>
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
<li>To provide the service you signed up for (Waitlist, Monitoring).</li>
<li>To manage our relationship with you.</li>
<li>To improve our website, products/services, marketing and customer relationships.</li>
</ul>
<h3>4. Contact Us</h3>
<p>
If you have any questions about this privacy policy or our privacy practices, please contact us at:
</p>
<div className="p-4 bg-secondary/20 rounded-lg border border-border">
<p className="font-semibold">Alertify Support</p>
<p>Email: <a href="mailto:support@qrmaster.net" className="text-primary hover:underline">support@qrmaster.net</a></p>
</div>
</section>
</div>
</div>
<Footer />
</div>
)
}

View File

@ -3,6 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
@ -59,21 +60,14 @@ export default function RegisterPage() {
<div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<svg
className="h-7 w-7 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<Image
src="/logo.png"
alt="Alertify Logo"
fill
className="object-contain"
priority
/>
</div>
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
<CardDescription>

View File

@ -0,0 +1,34 @@
'use client'
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, Suspense } from "react"
import { usePostHog } from 'posthog-js/react'
function PostHogPageViewContent() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
useEffect(() => {
// Track pageview
if (pathname && posthog) {
let url = window.origin + pathname
if (searchParams.toString()) {
url = url + `?${searchParams.toString()}`
}
posthog.capture('$pageview', {
'$current_url': url,
})
}
}, [pathname, searchParams, posthog])
return null
}
export default function PostHogPageView() {
return (
<Suspense fallback={null}>
<PostHogPageViewContent />
</Suspense>
)
}

View File

@ -0,0 +1,25 @@
'use client'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { useEffect } from 'react'
import PostHogPageView from './PostHogPageView'
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (typeof window !== 'undefined' && !posthog.__loaded) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || 'phc_placeholder_key', {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
capture_pageview: false, // Disable automatic pageview capture, as we handle it manually
capture_pageleave: true,
persistence: 'localStorage+cookie',
opt_out_capturing_by_default: true,
debug: true,
})
}
}, [])
return <PHProvider client={posthog}>
<PostHogPageView />
{children}
</PHProvider>
}

View File

@ -0,0 +1,66 @@
'use client'
import { useEffect, useState } from 'react'
import posthog from 'posthog-js'
import { Button } from '@/components/ui/button'
import { motion, AnimatePresence } from 'framer-motion'
import Link from 'next/link'
import { Cookie } from 'lucide-react'
export function CookieBanner() {
const [show, setShow] = useState(false)
useEffect(() => {
const optedIn = posthog.has_opted_in_capturing()
const optedOut = posthog.has_opted_out_capturing()
if (!optedIn && !optedOut) {
setShow(true)
}
}, [])
const handleAccept = () => {
posthog.opt_in_capturing()
setShow(false)
}
const handleDecline = () => {
posthog.opt_out_capturing()
setShow(false)
}
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="fixed bottom-4 right-4 z-[100] max-w-sm w-full p-4"
>
<div className="rounded-xl border border-border bg-background/95 p-6 shadow-2xl backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="flex items-start gap-4">
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Cookie className="h-6 w-6" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-2">We value your privacy</h3>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
We use cookies to enhance your browsing experience and analyze our traffic. By clicking "Accept", you consent to our use of cookies.
Read our <Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link>.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={handleDecline} className="flex-1">
Decline
</Button>
<Button onClick={handleAccept} className="flex-1">
Accept
</Button>
</div>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@ -36,9 +36,9 @@ export function CompetitorDemoVisual() {
<motion.div
className="p-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 relative overflow-hidden shadow-xl"
animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a',
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1
? '0 0 20px rgba(239, 68, 68, 0.2)'
? '0 0 20px hsl(var(--burgundy) / 0.2)'
: '0 1px 3px rgba(0,0,0,0.5)'
}}
transition={{ duration: 0.5 }}
@ -49,7 +49,7 @@ export function CompetitorDemoVisual() {
initial={{ x: '-100%', skewX: -20 }}
animate={{ x: '200%' }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="absolute inset-0 bg-gradient-to-r from-transparent via-red-500/10 to-transparent"
className="absolute inset-0 bg-gradient-to-r from-transparent via-[hsl(var(--burgundy))]/10 to-transparent"
/>
)}
@ -67,7 +67,7 @@ export function CompetitorDemoVisual() {
className="text-3xl font-bold"
animate={{
textDecoration: phase === 1 ? 'line-through' : 'none',
color: phase === 1 ? '#ef4444' : '#f4f4f5'
color: phase === 1 ? 'hsl(var(--burgundy))' : '#f4f4f5'
}}
>
$99
@ -84,14 +84,14 @@ export function CompetitorDemoVisual() {
transition={{ delay: 0.1, type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-3 mt-1"
>
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-red-500/10">
<ArrowDown className="h-4 w-4 text-red-500" strokeWidth={3} />
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-[hsl(var(--burgundy))]/10">
<ArrowDown className="h-4 w-4 text-[hsl(var(--burgundy))]" strokeWidth={3} />
</div>
<div className="flex items-baseline gap-2">
<span className="text-5xl font-extrabold text-[#ff0000] tracking-tight">
<span className="text-5xl font-extrabold text-[hsl(var(--burgundy))] tracking-tight">
$79
</span>
<span className="text-sm font-medium text-red-500">/month</span>
<span className="text-sm font-medium text-[hsl(var(--burgundy))]">/month</span>
</div>
</motion.div>
)}
@ -102,9 +102,9 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, scale: 0.8, rotate: -3 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ delay: 0.3, type: 'spring' }}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-500/10 border border-red-500/20"
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/20"
>
<span className="text-[10px] font-extrabold text-red-500 uppercase tracking-wider">
<span className="text-[10px] font-extrabold text-[hsl(var(--burgundy))] uppercase tracking-wider">
Save $240/year
</span>
</motion.div>
@ -119,17 +119,17 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.6 }}
className="flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<div className="relative flex-shrink-0">
<Bell className="h-3 w-3 text-red-500" />
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
<motion.span
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1, repeat: Infinity }}
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-red-500"
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
/>
</div>
<span className="text-[9px] font-semibold text-red-500">
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
Alert sent to your team
</span>
</motion.div>

View File

@ -115,7 +115,7 @@ export function HeroSection() {
custom={4}
className="w-full max-w-lg"
>
<WaitlistForm />
<WaitlistForm id="waitlist-form" />
</motion.div>
{/* Trust Signals */}
@ -136,7 +136,7 @@ export function HeroSection() {
<span></span>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current" />
<span>Early access bonus</span>
<span>Early access</span>
</div>
</motion.div>
</motion.div>
@ -765,7 +765,7 @@ export function FinalCTA() {
>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
<span>Early access</span>
</div>
</motion.div>
</motion.div>

View File

@ -161,7 +161,7 @@ export function LiveSerpPreview() {
<Button
variant="outline"
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10"
onClick={() => document.getElementById('waitlist-form')?.scrollIntoView({ behavior: 'smooth' })}
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
>
Get notified on changes
<ArrowRight className="ml-2 h-4 w-4" />

View File

@ -39,9 +39,9 @@ export function PolicyDemoVisual() {
<motion.div
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a',
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1
? '0 0 20px rgba(239, 68, 68, 0.2)'
? '0 0 20px hsl(var(--burgundy) / 0.2)'
: '0 1px 3px rgba(0,0,0,0.2)'
}}
transition={{ duration: 0.5 }}
@ -63,10 +63,10 @@ export function PolicyDemoVisual() {
>
<motion.p
animate={{
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
paddingLeft: phase === 1 ? '4px' : '0px',
paddingRight: phase === 1 ? '4px' : '0px',
color: phase === 1 ? '#ef4444' : 'inherit',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit',
fontWeight: phase === 1 ? 600 : 400
}}
transition={{ duration: 0.4 }}
@ -91,7 +91,7 @@ export function PolicyDemoVisual() {
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4 }}
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-red-500 rounded-full origin-left"
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
/>
)}
</motion.div>
@ -114,7 +114,7 @@ export function PolicyDemoVisual() {
+18 words
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
<span className="w-2 h-2 rounded bg-[hsl(var(--burgundy))]/20 border border-[hsl(var(--burgundy))]" />
-7 words
</span>
</div>
@ -128,16 +128,16 @@ export function PolicyDemoVisual() {
initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.5 }}
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white">
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--burgundy))] text-white">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
<div className="flex-1">
<div className="text-[9px] font-bold text-red-500">
<div className="text-[9px] font-bold text-[hsl(var(--burgundy))]">
Audit trail saved
</div>
<div className="text-[8px] text-red-500/80">
<div className="text-[8px] text-[hsl(var(--burgundy))]/80">
Snapshot archived for compliance
</div>
</div>

View File

@ -59,10 +59,10 @@ export function SEODemoVisual() {
<motion.div
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
animate={{
borderColor: phase === 0 ? '#27272a' : '#ef4444',
borderColor: phase === 0 ? '#27272a' : 'hsl(var(--burgundy))',
boxShadow: phase === 0
? '0 1px 3px rgba(0,0,0,0.2)'
: '0 0 20px rgba(239, 68, 68, 0.2)'
: '0 0 20px hsl(var(--burgundy) / 0.2)'
}}
transition={{ duration: 0.5 }}
>
@ -86,8 +86,8 @@ export function SEODemoVisual() {
>
<motion.span
animate={{
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
color: phase === 1 ? '#ef4444' : 'inherit'
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit'
}}
transition={{ duration: 0.5 }}
className="inline-block rounded px-0.5"
@ -100,7 +100,7 @@ export function SEODemoVisual() {
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-red-500 text-[8px] font-bold text-white"
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
>
Changed
</motion.span>

View File

@ -5,7 +5,11 @@ import { useState } from 'react'
import { Check, ArrowRight, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function WaitlistForm() {
interface WaitlistFormProps {
id?: string
}
export function WaitlistForm({ id }: WaitlistFormProps) {
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
@ -160,7 +164,7 @@ export function WaitlistForm() {
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
>
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
🎉 Early access: 50% off for 6 months
🎉 Early access
</span>
</motion.div>
</motion.div>
@ -170,65 +174,69 @@ export function WaitlistForm() {
return (
<motion.form
id={id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
onSubmit={handleSubmit}
className="max-w-md mx-auto"
>
<div className="flex flex-col sm:flex-row gap-3">
{/* Email Input */}
<motion.div
className="flex-1 relative"
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
transition={{ duration: 0.4 }}
>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
placeholder="Enter your email"
disabled={isSubmitting}
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3">
{/* Email Input */}
<motion.div
className="flex-1 relative"
>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
placeholder="Enter your email"
disabled={isSubmitting}
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
} disabled:opacity-50 disabled:cursor-not-allowed`}
/>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
>
{error}
</motion.div>
)}
</AnimatePresence>
</motion.div>
} disabled:opacity-50 disabled:cursor-not-allowed`}
/>
</motion.div>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting || !email}
size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining...
</>
) : (
<>
Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting || !email}
size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining...
</>
) : (
<>
Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
</div>
{/* Error Message - Visibility Improved */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="text-red-600 bg-red-50 px-4 py-2 rounded-lg text-sm font-medium border border-red-100 flex items-center gap-2"
>
<div className="h-1.5 w-1.5 rounded-full bg-red-500 flex-shrink-0" />
{error}
</motion.div>
)}
</Button>
</AnimatePresence>
</div>
{/* Trust Signals Below Form */}

View File

@ -0,0 +1,65 @@
import Link from 'next/link'
import Image from 'next/image'
import { Globe } from 'lucide-react'
export function Footer() {
return (
<footer className="border-t border-border bg-background py-12 text-sm">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
<div className="md:col-span-2">
<div className="mb-6 flex items-center gap-2">
<div className="relative h-8 w-8">
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
</div>
<span className="text-lg font-bold text-foreground">Alertify</span>
</div>
<p className="text-muted-foreground max-w-xs mb-6">
The modern platform for uptime monitoring, change detection, and performance tracking.
</p>
<div className="flex gap-4">
{/* Social icons placeholders */}
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
<Globe className="h-4 w-4" />
</div>
</div>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="/#features" className="hover:text-primary transition-colors">Features</Link></li>
<li><Link href="/#use-cases" className="hover:text-primary transition-colors">Use Cases</Link></li>
</ul>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="/blog" className="hover:text-primary transition-colors">Blog</Link></li>
</ul>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy</Link></li>
<li><Link href="/admin" className="hover:text-primary transition-colors opacity-50 text-xs">Admin</Link></li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
<p>© 2026 Alertify. All rights reserved.</p>
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
System Operational
</div>
</div>
</div>
</footer>
)
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
@ -86,8 +86,15 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
},
})
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// Default to stored user plan from localStorage if API fails or is loading
const getStoredPlan = () => {
if (!mounted) return 'free'
if (typeof window !== 'undefined') {
try {
const userStr = localStorage.getItem('user');
@ -98,8 +105,8 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
}
// Capitalize plan name
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
const currentPlan = settingsData?.plan || getStoredPlan() || 'free'
const planName = currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1);
// Determine badge color
const getBadgeVariant = (plan: string) => {

View File

@ -0,0 +1,101 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Sparkline } from '@/components/sparkline'
interface Props {
monitorId: string
keywords: string[]
}
export function SEORankingCard({ monitorId, keywords }: Props) {
const { data: rankings, isLoading } = useQuery({
queryKey: ['rankings', monitorId],
queryFn: async () => {
const response = await monitorAPI.rankings(monitorId)
return response // { history: [], latest: [] }
}
})
if (isLoading) {
return (
<Card>
<CardContent className="py-6 flex justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</CardContent>
</Card>
)
}
const { latest = [], history = [] } = rankings || {}
// Group history by keyword for sparklines
const historyByKeyword = (history as any[]).reduce((acc, item) => {
if (!acc[item.keyword]) acc[item.keyword] = []
acc[item.keyword].push(item)
return acc
}, {} as Record<string, any[]>)
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{keywords.map(keyword => {
const latestRank = latest.find((r: any) => r.keyword === keyword)
const keywordHistory = historyByKeyword[keyword] || []
// Sort history by date asc for sparkline
const rankHistory = keywordHistory
.sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((item: any) => item.rank || 101) // Use 101 for unranked
return (
<Card key={keyword} className="overflow-hidden">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex justify-between items-start">
<span className="truncate pr-2" title={keyword}>{keyword}</span>
{latestRank?.rank ? (
<Badge variant={latestRank.rank <= 3 ? 'success' : latestRank.rank <= 10 ? 'default' : 'secondary'}>
#{latestRank.rank}
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Not found
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-xs text-muted-foreground mb-3">
{latestRank?.urlFound ? (
<a href={latestRank.urlFound} target="_blank" rel="noopener noreferrer" className="hover:underline truncate block">
{new URL(latestRank.urlFound).pathname}
</a>
) : (
<span>Not in top 100</span>
)}
</div>
{rankHistory.length > 1 && (
<div className="h-10 w-full mt-2">
{/* Simple visualization if Sparkline component accepts array */}
<Sparkline
data={rankHistory}
color={latestRank?.rank ? "#8b5cf6" : "#cbd5e1"}
height={40}
width={100}
/>
</div>
)}
<div className="mt-2 text-[10px] text-muted-foreground text-right">
Last checked: {latestRank ? new Date(latestRank.createdAt).toLocaleDateString() : 'Never'}
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@ -91,8 +91,8 @@ export const monitorAPI = {
return response.data;
},
check: async (id: string) => {
const response = await api.post(`/monitors/${id}/check`);
check: async (id: string, type: 'all' | 'content' | 'seo' = 'all') => {
const response = await api.post(`/monitors/${id}/check`, { type });
return response.data;
},
@ -103,6 +103,13 @@ export const monitorAPI = {
return response.data;
},
rankings: async (id: string, limit = 100) => {
const response = await api.get(`/monitors/${id}/rankings`, {
params: { limit },
});
return response.data;
},
snapshot: async (id: string, snapshotId: string) => {
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
return response.data;

View File

@ -4,6 +4,12 @@ export interface Monitor {
name: string
frequency: number
status: 'active' | 'paused' | 'error'
elementSelector?: string
ignoreRules?: { type: 'css' | 'regex' | 'text', value: string }[]
keywordRules?: { keyword: string, type: 'appears' | 'disappears' | 'count', threshold?: number, caseSensitive?: boolean }[]
seoKeywords?: string[]
seoInterval?: 'daily' | '2d' | 'weekly' | 'monthly' | 'off'
lastSeoCheckAt?: string
last_checked_at?: string
last_changed_at?: string
consecutive_errors: number

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,10 @@
"clsx": "^2.1.0",
"date-fns": "^3.0.6",
"framer-motion": "^12.27.0",
"jimp": "^1.6.0",
"lucide-react": "^0.303.0",
"next": "14.0.4",
"posthog-js": "^1.331.1",
"react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.0",

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

34
frontend/remove_bg.js Normal file
View File

@ -0,0 +1,34 @@
const JimpModule = require('jimp');
console.log('Jimp exports:', Object.keys(JimpModule));
const Jimp = JimpModule.Jimp || JimpModule;
const path = require('path');
async function removeBackground(inputPath, outputPath) {
try {
const image = await Jimp.read(inputPath);
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
const red = this.bitmap.data[idx + 0];
const green = this.bitmap.data[idx + 1];
const blue = this.bitmap.data[idx + 2];
// If white (or close to white), make transparent
if (red > 240 && green > 240 && blue > 240) {
this.bitmap.data[idx + 3] = 0;
}
});
image.write(outputPath, (err) => {
if (err) {
console.error('Error writing file:', err);
} else {
console.log('Successfully removed background');
}
});
} catch (err) {
console.error('Error:', err);
}
}
const logoPath = path.join(__dirname, 'public', 'logo.png');
removeBackground(logoPath, logoPath);

24
frontend/remove_bg.py Normal file
View File

@ -0,0 +1,24 @@
from PIL import Image
def remove_background(input_path, output_path):
try:
img = Image.open(input_path)
img = img.convert("RGBA")
datas = img.getdata()
newData = []
for item in datas:
# Change all white (also shades of whites)
# to transparent
if item[0] > 240 and item[1] > 240 and item[2] > 240:
newData.append((255, 255, 255, 0))
else:
newData.append(item)
img.putdata(newData)
img.save(output_path, "PNG")
print("Successfully removed background")
except Exception as e:
print(f"Error: {e}")
remove_background("c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png", "c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png")