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

View File

@ -33,6 +33,7 @@
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"openai": "^6.16.0", "openai": "^6.16.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"serpapi": "^2.2.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "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]; let value = obj[key];
// Parse JSON fields that are stored as strings in the database // 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 { try {
value = JSON.parse(value); value = JSON.parse(value);
} catch (e) { } catch (e) {
@ -164,8 +164,8 @@ export const db = {
const result = await query( const result = await query(
`INSERT INTO monitors ( `INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector, user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules ignore_rules, keyword_rules, seo_keywords, seo_interval
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
[ [
data.userId, data.userId,
data.url, data.url,
@ -175,6 +175,8 @@ export const db = {
data.elementSelector || null, data.elementSelector || null,
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null, data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
data.keywordRules ? JSON.stringify(data.keywordRules) : null, data.keywordRules ? JSON.stringify(data.keywordRules) : null,
data.seoKeywords ? JSON.stringify(data.seoKeywords) : null,
data.seoInterval || 'off',
] ]
); );
return toCamelCase<Monitor>(result.rows[0]); return toCamelCase<Monitor>(result.rows[0]);
@ -220,9 +222,12 @@ export const db = {
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase(); 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}`); fields.push(`${snakeKey} = $${paramCount}`);
values.push(JSON.stringify(value)); values.push(JSON.stringify(value));
} else if (key === 'seoInterval') {
fields.push(`seo_interval = $${paramCount}`);
values.push(value);
} else { } else {
fields.push(`${snakeKey} = $${paramCount}`); fields.push(`${snakeKey} = $${paramCount}`);
values.push(value); values.push(value);
@ -433,6 +438,47 @@ export const db = {
return result.rows.map(row => toCamelCase<any>(row)); 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; 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() updated_at TIMESTAMP DEFAULT NOW()
); );
CREATE INDEX idx_users_email ON users(email); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX idx_users_plan ON users(plan); 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_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; 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() updated_at TIMESTAMP DEFAULT NOW()
); );
CREATE INDEX idx_monitors_user_id ON monitors(user_id); CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors(user_id);
CREATE INDEX idx_monitors_status ON monitors(status); CREATE INDEX IF NOT EXISTS 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_last_checked_at ON monitors(last_checked_at);
-- Snapshots table -- Snapshots table
CREATE TABLE IF NOT EXISTS snapshots ( CREATE TABLE IF NOT EXISTS snapshots (
@ -62,9 +62,9 @@ CREATE TABLE IF NOT EXISTS snapshots (
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
); );
CREATE INDEX idx_snapshots_monitor_id ON snapshots(monitor_id); CREATE INDEX IF NOT EXISTS idx_snapshots_monitor_id ON snapshots(monitor_id);
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at); CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON snapshots(created_at);
CREATE INDEX idx_snapshots_changed ON snapshots(changed); CREATE INDEX IF NOT EXISTS idx_snapshots_changed ON snapshots(changed);
-- Alerts table -- Alerts table
CREATE TABLE IF NOT EXISTS alerts ( CREATE TABLE IF NOT EXISTS alerts (
@ -81,10 +81,10 @@ CREATE TABLE IF NOT EXISTS alerts (
created_at TIMESTAMP DEFAULT NOW() created_at TIMESTAMP DEFAULT NOW()
); );
CREATE INDEX idx_alerts_user_id ON alerts(user_id); CREATE INDEX IF NOT EXISTS idx_alerts_user_id ON alerts(user_id);
CREATE INDEX idx_alerts_monitor_id ON alerts(monitor_id); CREATE INDEX IF NOT EXISTS idx_alerts_monitor_id ON alerts(monitor_id);
CREATE INDEX idx_alerts_created_at ON alerts(created_at); CREATE INDEX IF NOT EXISTS 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_read_at ON alerts(read_at);
-- Update timestamps trigger -- Update timestamps trigger
CREATE OR REPLACE FUNCTION update_updated_at_column() 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_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(), .optional(),
seoKeywords: z.array(z.string()).optional(),
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
}); });
const updateMonitorSchema = z.object({ const updateMonitorSchema = z.object({
@ -56,6 +58,8 @@ const updateMonitorSchema = z.object({
}) })
) )
.optional(), .optional(),
seoKeywords: z.array(z.string()).optional(),
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
}); });
// Get plan limits // 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); const monitors = await db.monitors.findByUserId(req.user.userId);
// Attach recent snapshots to each monitor for sparklines // Attach recent snapshots and latest rankings to each monitor
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => { const monitorsWithData = await Promise.all(monitors.map(async (monitor) => {
// Get last 20 snapshots for sparkline // Get last 20 snapshots for sparkline
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20); const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
// Get latest rankings
const latestRankings = await db.rankings.findLatestByMonitorId(monitor.id);
return { return {
...monitor, ...monitor,
recentSnapshots recentSnapshots,
latestRankings
}; };
})); }));
res.json({ monitors: monitorsWithSnapshots }); res.json({ monitors: monitorsWithData });
} catch (error) { } catch (error) {
console.error('List monitors error:', error); console.error('List monitors error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' }); 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, elementSelector: input.elementSelector,
ignoreRules: input.ignoreRules, ignoreRules: input.ignoreRules,
keywordRules: input.keywordRules, keywordRules: input.keywordRules,
seoKeywords: input.seoKeywords,
}); });
// Schedule recurring checks // 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 // Await the check so user gets immediate feedback
try { 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 // Get the latest snapshot to return to the user
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id); 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) // Export monitor audit trail (JSON or CSV)
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => { router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
try { try {

View File

@ -5,40 +5,120 @@ const router = Router();
router.get('/dynamic', (_req, res) => { router.get('/dynamic', (_req, res) => {
const now = new Date(); const now = new Date();
const timeString = now.toLocaleTimeString(); const minute = now.getMinutes();
const randomValue = Math.floor(Math.random() * 1000); const second = now.getSeconds();
// Toggle status based on seconds (even/odd) to guarantee change const tenSecondBlock = Math.floor(second / 10);
const isNormal = now.getSeconds() % 2 === 0;
const statusMessage = isNormal // Dynamic Pricing Logic - changes every 10 seconds
? "System Status: NORMAL - Everything is running smoothly." const basicPrice = 9 + (tenSecondBlock % 5);
: "System Status: WARNING - High load detected on server node!"; const proPrice = 29 + (tenSecondBlock % 10);
const statusColor = isNormal ? "green" : "red"; 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 = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head> <head>
<title>Dynamic Test Page</title> <meta charset="UTF-8">
<title>CloudScale SaaS - Infrastructure for Growth</title>
<style> <style>
body { font-family: sans-serif; padding: 20px; } body { font-family: 'Inter', -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 1000px; margin: 0 auto; padding: 40px; }
.time { font-size: 2em; color: #0066cc; } header { text-align: center; margin-bottom: 50px; }
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; } .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> </style>
</head> </head>
<body> <body>
<h1>Website Monitor Test</h1> <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="status">${statusMessage}</div> <div class="pricing-section">
<h2 style="text-align: center">Simple, Transparent Pricing</h2>
<div class="content"> <div class="grid">
<p>Current Time: <span class="time">${timeString}</span></p> <div class="card">
<p>Random Value: <span class="random">${randomValue}</span></p> <h3>Basic</h3>
<p>This page content flips every second to simulate a real website change.</p> <div class="price">$${basicPrice}<span>/mo</span></div>
<div style="background: #f0f9ff; padding: 15px; margin-top: 20px; border-left: 4px solid #0066cc;"> <ul>
<h3>New Feature Update</h3> <li>5 Projects</li>
<p>We have deployed a new importance scoring update!</p> <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>
</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> </body>
</html> </html>
`; `;

View File

@ -20,10 +20,21 @@ router.post('/meta-preview', async (req, res) => {
const response = await axios.get(url, { const response = await axios.get(url, {
headers: { 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, timeout: 30000,
validateStatus: (status) => status < 500 // Resolve even if 404/403 to avoid crashing flow immediately 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; const html = response.data;

View File

@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { pool } from '../db'; import { query } from '../db';
import { z } from 'zod'; import { z } from 'zod';
const router = Router(); const router = Router();
@ -17,14 +17,14 @@ router.post('/', async (req, res) => {
const data = waitlistSchema.parse(req.body); const data = waitlistSchema.parse(req.body);
// Check if email already exists // Check if email already exists
const existing = await pool.query( const existing = await query(
'SELECT id FROM waitlist_leads WHERE email = $1', 'SELECT id FROM waitlist_leads WHERE email = $1',
[data.email.toLowerCase()] [data.email.toLowerCase()]
); );
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
// Already on waitlist - return success anyway (don't reveal they're already signed up) // 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); const position = parseInt(countResult.rows[0].count, 10);
return res.json({ return res.json({
@ -36,13 +36,13 @@ router.post('/', async (req, res) => {
} }
// Insert new lead // Insert new lead
await pool.query( await query(
'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)', 'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)',
[data.email.toLowerCase(), data.source, data.referrer || null] [data.email.toLowerCase(), data.source, data.referrer || null]
); );
// Get current position (total count) // 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); const position = parseInt(countResult.rows[0].count, 10);
console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`); console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`);
@ -73,7 +73,7 @@ router.post('/', async (req, res) => {
// GET /api/waitlist/count - Get current waitlist count (public) // GET /api/waitlist/count - Get current waitlist count (public)
router.get('/count', async (_req, res) => { router.get('/count', async (_req, res) => {
try { 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); const count = parseInt(result.rows[0].count, 10);
// Add a base number to make it look more impressive at launch // Add a base number to make it look more impressive at launch
@ -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; export default router;

View File

@ -10,14 +10,19 @@ import {
import { calculateChangeImportance } from './importance'; import { calculateChangeImportance } from './importance';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter'; import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
import { generateSimpleSummary, generateAISummary } from './summarizer'; import { generateSimpleSummary, generateAISummary } from './summarizer';
import { processSeoChecks } from './seo';
export interface CheckResult { export interface CheckResult {
snapshot: Snapshot; snapshot: Snapshot;
alertSent: boolean; alertSent: boolean;
} }
export async function checkMonitor(monitorId: string): Promise<CheckResult | void> { export async function checkMonitor(
console.log(`[Monitor] Checking monitor ${monitorId}`); 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 { try {
const monitor = await db.monitors.findById(monitorId); 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') { 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; return;
} }
// Fetch page with retries let snapshot: Snapshot | undefined;
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector); let changed = false;
// Retry on failure (max 3 attempts) // Content Check Part
if (fetchResult.error) { if (checkType === 'all' || checkType === 'content') {
console.log(`[Monitor] Fetch failed, retrying... (1/3)`); console.log(`[Monitor] Running CONTENT check for ${monitor.name} (${monitor.url})`);
await new Promise((resolve) => setTimeout(resolve, 2000)); // Fetch page with retries
fetchResult = await fetchPage(monitor.url, monitor.elementSelector); let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
// Retry on failure (max 3 attempts)
if (fetchResult.error) { 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)); await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult = await fetchPage(monitor.url, monitor.elementSelector); fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
}
}
if (fetchResult.error) {
// Check for HTTP error status console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
if (!fetchResult.error && fetchResult.status >= 400) { await new Promise((resolve) => setTimeout(resolve, 2000));
fetchResult.error = `HTTP ${fetchResult.status}`; fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
}
// 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);
} }
} }
// 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 // Create error snapshot
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules)); const failedSnapshot = await db.snapshots.create({
let processedHtml = applyCommonNoiseFilters(fetchResult.html); monitorId: monitor.id,
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules); htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: fetchResult.status,
responseTime: fetchResult.responseTime,
changed: false,
errorMessage: fetchResult.error,
});
// Get previous snapshot await db.monitors.incrementErrors(monitor.id);
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
let changed = false; // Send error alert if consecutive errors > 3
let changePercentage = 0; if (monitor.consecutiveErrors >= 2) {
let diffResult: ReturnType<typeof compareDiffs> | undefined; const user = await db.users.findById(monitor.userId);
if (user) {
await sendErrorAlert(monitor, user, fetchResult.error);
}
}
if (previousSnapshot) { return { snapshot: failedSnapshot, alertSent: false };
// Apply same filters to previous content for fair comparison }
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
// Compare // Apply noise filters
diffResult = compareDiffs(previousHtml, processedHtml); let processedHtml = applyCommonNoiseFilters(fetchResult.html);
changed = diffResult.changed; processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
changePercentage = diffResult.changePercentage;
console.log( // Get previous snapshot
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}` const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
);
// Check keywords let changePercentage = 0;
if (monitor.keywordRules && monitor.keywordRules.length > 0) { let diffResult: ReturnType<typeof compareDiffs> | undefined;
const keywordMatches = checkKeywords(
previousHtml, if (previousSnapshot) {
processedHtml, // Apply same filters to previous content for fair comparison
monitor.keywordRules 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) { // Check keywords
console.log(`[Monitor] Keyword matches found:`, keywordMatches); if (monitor.keywordRules && monitor.keywordRules.length > 0) {
const user = await db.users.findById(monitor.userId); const keywordMatches = checkKeywords(
previousHtml,
processedHtml,
monitor.keywordRules
);
if (user) { if (keywordMatches.length > 0) {
for (const match of keywordMatches) { console.log(`[Monitor] Keyword matches found:`, keywordMatches);
await sendKeywordAlert(monitor, user, match); 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) // Generate human-readable summary (Hybrid approach)
let summary: string | undefined; let summary: string | undefined;
if (changed && previousSnapshot && diffResult) { if (changed && previousSnapshot && diffResult) {
// Hybrid logic: AI for changes (≥5%), simple for very small changes // Hybrid logic: AI for changes (≥5%), simple for very small changes
if (changePercentage >= 5) { if (changePercentage >= 5) {
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`); console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
try { try {
summary = await generateAISummary(diffResult.diff, changePercentage); summary = await generateAISummary(diffResult.diff, changePercentage, monitor.url);
} catch (error) { } catch (error) {
console.error('[Monitor] AI summary failed, falling back to simple summary:', 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( summary = generateSimpleSummary(
diffResult.diff, diffResult.diff,
previousSnapshot.htmlContent, previousSnapshot.htmlContent,
fetchResult.html fetchResult.html
); );
} }
} else {
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
} }
}
// Create snapshot // Create snapshot
const snapshot = await db.snapshots.create({ console.log(`[Monitor] Creating snapshot in DB for ${monitor.name}`);
monitorId: monitor.id, snapshot = await db.snapshots.create({
htmlContent: fetchResult.html, monitorId: monitor.id,
textContent: fetchResult.text, htmlContent: fetchResult.html,
contentHash: fetchResult.hash, textContent: fetchResult.text,
httpStatus: fetchResult.status, contentHash: fetchResult.hash,
responseTime: fetchResult.responseTime, httpStatus: fetchResult.status,
changed, responseTime: fetchResult.responseTime,
changePercentage: changed ? changePercentage : undefined, changed,
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0, changePercentage: changed ? changePercentage : undefined,
summary, importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
}); summary,
});
// Update monitor // Update monitor
await db.monitors.updateLastChecked(monitor.id, changed); await db.monitors.updateLastChecked(monitor.id, changed);
// Send alert if changed and not first check // Send alert if changed and not first check
if (changed && previousSnapshot) { if (changed && previousSnapshot) {
try { try {
const user = await db.users.findById(monitor.userId); const user = await db.users.findById(monitor.userId);
if (user) { if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage); 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 // SEO Check Part
try { if ((checkType === 'all' || checkType === 'seo') && monitor.seoKeywords && monitor.seoKeywords.length > 0) {
const retentionUser = await db.users.findById(monitor.userId); let shouldRunSeo = false;
if (retentionUser) {
const { getRetentionDays } = await import('../config'); if (forceSeo) {
const retentionDays = getRetentionDays(retentionUser.plan); console.log(`[Monitor] SEO check triggered manually for ${monitor.name}`);
await db.snapshots.deleteOldSnapshotsByAge(monitor.id, retentionDays); 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}`); console.log(`[Monitor] Check completed for ${monitor.name} (Snapshot created: ${!!snapshot})`);
return { snapshot, alertSent: changed && !!previousSnapshot }; return {
snapshot,
alertSent: changed
};
} catch (error) { } catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error); console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId); 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 // Add text preview if available
if (textPreviews.length > 0) { if (textPreviews.length > 0) {
const previewText = textPreviews.slice(0, 2).join(' → '); const previewText = textPreviews.join(' | ');
if (summary) { if (summary) {
summary += `. Changed: "${previewText}"`; summary += `. Details: ${previewText}`;
} else { } 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( export async function generateAISummary(
diff: Change[], diff: Change[],
changePercentage: number changePercentage: number,
url?: string
): Promise<string> { ): Promise<string> {
try { try {
// Check if API key is configured // Check if API key is configured
@ -229,17 +230,30 @@ export async function generateAISummary(
// Format diff for AI (reduce token count) // Format diff for AI (reduce token count)
const formattedDiff = formatDiffForAI(diff); const formattedDiff = formatDiffForAI(diff);
const prompt = `Analyze this website change and create a concise summary for non-programmers. const prompt = `Analyze the website changes for: ${url || 'unknown'}
Focus on IMPORTANT changes only. Medium detail level. 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} ${formattedDiff}
Format: "Section name: What changed. Details if important." FORMAT:
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer." - Start with a single summary sentence.
Keep it under 100 words. Be specific about what changed, not how.`; - 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({ const completion = await client.chat.completions.create({
model: 'gpt-4o-mini', // Fastest, cheapest model: 'gpt-4o-mini', // Fastest, cheapest

View File

@ -47,6 +47,9 @@ export interface Monitor {
elementSelector?: string; elementSelector?: string;
ignoreRules?: IgnoreRule[]; ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[]; keywordRules?: KeywordRule[];
seoKeywords?: string[];
seoInterval?: string;
lastSeoCheckAt?: Date;
lastCheckedAt?: Date; lastCheckedAt?: Date;
lastChangedAt?: Date; lastChangedAt?: Date;
consecutiveErrors: number; consecutiveErrors: number;
@ -98,6 +101,7 @@ export interface CreateMonitorInput {
elementSelector?: string; elementSelector?: string;
ignoreRules?: IgnoreRule[]; ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[]; keywordRules?: KeywordRule[];
seoInterval?: string;
} }
export interface UpdateMonitorInput { export interface UpdateMonitorInput {
@ -107,4 +111,5 @@ export interface UpdateMonitorInput {
elementSelector?: string; elementSelector?: string;
ignoreRules?: IgnoreRule[]; ignoreRules?: IgnoreRule[];
keywordRules?: KeywordRule[]; 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 = { export const metadata: Metadata = {
title: 'Website Monitor - Track Changes on Any Website', title: 'Alertify - Track Changes on Any Website',
description: 'Monitor website changes with smart filtering and instant alerts', description: 'Alertify helps you track website changes in real-time. Get notified instantly when content updates.',
} }
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
import { CookieBanner } from '@/components/compliance/CookieBanner'
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
@ -32,8 +35,11 @@ export default function RootLayout({
return ( return (
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}> <html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
<body className={interTight.className}> <body className={interTight.className}>
<Providers>{children}</Providers> <PostHogProvider>
<Toaster richColors position="top-right" /> <Providers>{children}</Providers>
<CookieBanner />
<Toaster richColors position="top-right" />
</PostHogProvider>
</body> </body>
</html> </html>
) )

View File

@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api' import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth' import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -40,30 +41,18 @@ export default function LoginPage() {
<div className="w-full max-w-md animate-fade-in"> <div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50"> <Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2"> <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"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<svg <Image
className="h-7 w-7 text-primary" src="/logo.png"
xmlns="http://www.w3.org/2000/svg" alt="Alertify Logo"
fill="none" fill
viewBox="0 0 24 24" className="object-contain"
stroke="currentColor" priority
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> </div>
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle> <CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription> <CardDescription>
Sign in to your Website Monitor account Sign in to your Alertify account
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation' import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api' import { monitorAPI } from '@/lib/api'
@ -7,13 +8,17 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { SEORankingCard } from '@/components/seo-ranking-card'
import { toast } from 'sonner'
export default function MonitorHistoryPage() { export default function MonitorHistoryPage() {
const router = useRouter() const router = useRouter()
const params = useParams() const params = useParams()
const id = params?.id as string 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], queryKey: ['monitor', id],
queryFn: async () => { queryFn: async () => {
const response = await monitorAPI.get(id) 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], queryKey: ['history', id],
queryFn: async () => { queryFn: async () => {
const response = await monitorAPI.history(id) 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) { if (isLoading) {
return ( return (
<DashboardLayout> <DashboardLayout>
@ -66,6 +106,40 @@ export default function MonitorHistoryPage() {
</div> </div>
{monitor && ( {monitor && (
<div className="flex gap-2"> <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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -105,6 +179,19 @@ export default function MonitorHistoryPage() {
</div> </div>
</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 */} {/* History List */}
<div> <div>
<h2 className="mb-4 text-lg font-semibold">Check History</h2> <h2 className="mb-4 text-lg font-semibold">Check History</h2>
@ -181,7 +268,7 @@ export default function MonitorHistoryPage() {
{snapshot.summary && ( {snapshot.summary && (
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm"> <div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
<p className="font-medium text-foreground mb-1">Summary</p> <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>
)} )}
</div> </div>

View File

@ -158,7 +158,7 @@ export default function SnapshotDetailsPage() {
{snapshot.summary && ( {snapshot.summary && (
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4"> <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 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> </div>
)} )}
</CardContent> </CardContent>

View File

@ -87,6 +87,7 @@ export default function MonitorsPage() {
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan() const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState<string | null>(null) const [checkingId, setCheckingId] = useState<string | null>(null)
const [checkingSeoId, setCheckingSeoId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all') const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
@ -102,6 +103,8 @@ export default function MonitorsPage() {
threshold?: number threshold?: number
caseSensitive?: boolean caseSensitive?: boolean
}>, }>,
seoKeywords: [] as string[],
seoInterval: 'off',
}) })
const [showVisualSelector, setShowVisualSelector] = useState(false) const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false) const [showTemplates, setShowTemplates] = useState(false)
@ -131,6 +134,10 @@ export default function MonitorsPage() {
if (newMonitor.keywordRules.length > 0) { if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules payload.keywordRules = newMonitor.keywordRules
} }
if (newMonitor.seoKeywords.length > 0) {
payload.seoKeywords = newMonitor.seoKeywords
payload.seoInterval = newMonitor.seoInterval
}
if (editingId) { if (editingId) {
await monitorAPI.update(editingId, payload) await monitorAPI.update(editingId, payload)
@ -146,7 +153,9 @@ export default function MonitorsPage() {
frequency: 60, frequency: 60,
ignoreSelector: '', ignoreSelector: '',
selectedPreset: '', selectedPreset: '',
keywordRules: [] keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
}) })
setShowAddForm(false) setShowAddForm(false)
setEditingId(null) setEditingId(null)
@ -179,7 +188,9 @@ export default function MonitorsPage() {
frequency: monitor.frequency, frequency: monitor.frequency,
ignoreSelector, ignoreSelector,
selectedPreset, selectedPreset,
keywordRules: monitor.keywordRules || [] keywordRules: monitor.keywordRules || [],
seoKeywords: monitor.seoKeywords || [],
seoInterval: monitor.seoInterval || 'off',
}) })
setEditingId(monitor.id) setEditingId(monitor.id)
setShowAddForm(true) setShowAddForm(true)
@ -194,7 +205,9 @@ export default function MonitorsPage() {
frequency: 60, frequency: 60,
ignoreSelector: '', ignoreSelector: '',
selectedPreset: '', selectedPreset: '',
keywordRules: [] keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
}) })
} }
@ -223,38 +236,55 @@ export default function MonitorsPage() {
frequency: monitorData.frequency, frequency: monitorData.frequency,
ignoreSelector, ignoreSelector,
selectedPreset, selectedPreset,
keywordRules: monitorData.keywordRules as any[] keywordRules: monitorData.keywordRules as any[],
seoKeywords: [],
seoInterval: 'off',
}) })
setShowTemplates(false) setShowTemplates(false)
setShowAddForm(true) setShowAddForm(true)
} }
const handleCheckNow = async (id: string) => { const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
// Prevent multiple simultaneous checks // Prevent multiple simultaneous checks of the same type
if (checkingId !== null) return if (type === 'seo') {
if (checkingSeoId !== null) return
setCheckingSeoId(id)
} else {
if (checkingId !== null) return
setCheckingId(id)
}
setCheckingId(id)
try { try {
const result = await monitorAPI.check(id) const result = await monitorAPI.check(id, type)
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`) if (type === 'seo') {
} else if (result.snapshot?.changed) { toast.success('SEO Ranking check completed')
toast.success('Changes detected!', { // For SEO check, we might want to refresh rankings specifically if we had a way
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
} else { } 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() refetch()
} catch (err: any) { } catch (err: any) {
console.error('Failed to trigger check:', err) 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 { } finally {
setCheckingId(null) if (type === 'seo') {
setCheckingSeoId(null)
} else {
setCheckingId(null)
}
} }
} }
@ -646,6 +676,80 @@ export default function MonitorsPage() {
)} )}
</div> </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"> <div className="flex gap-3 pt-2">
<Button type="submit"> <Button type="submit">
{editingId ? 'Save Changes' : 'Create Monitor'} {editingId ? 'Save Changes' : 'Create Monitor'}
@ -714,16 +818,41 @@ export default function MonitorsPage() {
</div> </div>
{/* Stats Row */} {/* 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 className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
<div> <div>
<p className="font-semibold text-foreground">{monitor.frequency}m</p> <p className="font-semibold text-foreground">{monitor.frequency}m</p>
<p className="text-muted-foreground">Frequency</p> <p className="text-muted-foreground">Frequency</p>
</div> </div>
<div> <div>
{monitor.last_changed_at ? ( {monitor.lastChangedAt ? (
<> <>
<p className="font-semibold text-foreground"> <p className="font-semibold text-foreground">
{new Date(monitor.last_changed_at).toLocaleDateString()} {new Date(monitor.lastChangedAt).toLocaleDateString()}
</p> </p>
<p className="text-muted-foreground">Last Change</p> <p className="text-muted-foreground">Last Change</p>
</> </>
@ -737,9 +866,9 @@ export default function MonitorsPage() {
</div> </div>
{/* Last Checked */} {/* Last Checked */}
{monitor.last_checked_at ? ( {monitor.lastCheckedAt ? (
<p className="mb-4 text-xs text-muted-foreground"> <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>
) : ( ) : (
<p className="mb-4 text-xs text-muted-foreground"> <p className="mb-4 text-xs text-muted-foreground">
@ -747,9 +876,26 @@ export default function MonitorsPage() {
</p> </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 */} {/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.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}" "{monitor.recentSnapshots[0].summary}"
</p> </p>
)} )}
@ -784,12 +930,24 @@ export default function MonitorsPage() {
variant="outline" variant="outline"
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={() => handleCheckNow(monitor.id)} onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id} loading={checkingId === monitor.id}
disabled={checkingId !== null} disabled={checkingId !== null}
> >
{checkingId === monitor.id ? 'Checking...' : 'Check Now'} {checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button> </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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -887,7 +1045,7 @@ export default function MonitorsPage() {
)} )}
<div className="text-center"> <div className="text-center">
<p className="font-medium text-foreground"> <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>
<p className="text-xs">Last Change</p> <p className="text-xs">Last Change</p>
</div> </div>
@ -898,12 +1056,25 @@ export default function MonitorsPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleCheckNow(monitor.id)} onClick={() => handleCheckNow(monitor.id, 'content')}
loading={checkingId === monitor.id} loading={checkingId === monitor.id}
disabled={checkingId !== null} disabled={checkingId !== null}
> >
{checkingId === monitor.id ? 'Checking...' : 'Check Now'} {checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button> </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)}> <Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
Edit Edit
</Button> </Button>

View File

@ -3,10 +3,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ThemeToggle } from '@/components/ui/ThemeToggle' import { ThemeToggle } from '@/components/ui/ThemeToggle'
import { HeroSection } from '@/components/landing/LandingSections' import { HeroSection } from '@/components/landing/LandingSections'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import { Footer } from '@/components/layout/Footer'
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react' import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
// Dynamic imports for performance optimization (lazy loading) // 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="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"> <div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2 group"> <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"> <div className="relative h-8 w-8 transition-transform group-hover:scale-110">
<Monitor className="h-5 w-5 text-primary-foreground" /> <Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
</div> </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> </Link>
<nav className="hidden items-center gap-6 md:flex"> <nav className="hidden items-center gap-6 md:flex">
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link> <Link href="#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 /> <FinalCTA />
{/* Footer */} {/* Footer */}
< footer className="border-t border-border bg-background py-12 text-sm" > <Footer />
<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 >
</div > </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 { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { authAPI } from '@/lib/api' import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth' import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -59,21 +60,14 @@ export default function RegisterPage() {
<div className="w-full max-w-md animate-fade-in"> <div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50"> <Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2"> <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"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
<svg <Image
className="h-7 w-7 text-primary" src="/logo.png"
xmlns="http://www.w3.org/2000/svg" alt="Alertify Logo"
fill="none" fill
viewBox="0 0 24 24" className="object-contain"
stroke="currentColor" priority
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> </div>
<CardTitle className="text-2xl font-bold">Create account</CardTitle> <CardTitle className="text-2xl font-bold">Create account</CardTitle>
<CardDescription> <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 <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" 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={{ animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a', borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1 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)' : '0 1px 3px rgba(0,0,0,0.5)'
}} }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
@ -49,7 +49,7 @@ export function CompetitorDemoVisual() {
initial={{ x: '-100%', skewX: -20 }} initial={{ x: '-100%', skewX: -20 }}
animate={{ x: '200%' }} animate={{ x: '200%' }}
transition={{ duration: 0.8, ease: 'easeInOut' }} 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" className="text-3xl font-bold"
animate={{ animate={{
textDecoration: phase === 1 ? 'line-through' : 'none', textDecoration: phase === 1 ? 'line-through' : 'none',
color: phase === 1 ? '#ef4444' : '#f4f4f5' color: phase === 1 ? 'hsl(var(--burgundy))' : '#f4f4f5'
}} }}
> >
$99 $99
@ -84,14 +84,14 @@ export function CompetitorDemoVisual() {
transition={{ delay: 0.1, type: 'spring', stiffness: 300, damping: 20 }} transition={{ delay: 0.1, type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-3 mt-1" 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"> <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-red-500" strokeWidth={3} /> <ArrowDown className="h-4 w-4 text-[hsl(var(--burgundy))]" strokeWidth={3} />
</div> </div>
<div className="flex items-baseline gap-2"> <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 $79
</span> </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> </div>
</motion.div> </motion.div>
)} )}
@ -102,9 +102,9 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, scale: 0.8, rotate: -3 }} initial={{ opacity: 0, scale: 0.8, rotate: -3 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }} animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ delay: 0.3, type: 'spring' }} 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 Save $240/year
</span> </span>
</motion.div> </motion.div>
@ -119,17 +119,17 @@ export function CompetitorDemoVisual() {
initial={{ opacity: 0, y: 10, scale: 0.95 }} initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.6 }} 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"> <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 <motion.span
animate={{ scale: [1, 1.3, 1] }} animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1, repeat: Infinity }} 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> </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 Alert sent to your team
</span> </span>
</motion.div> </motion.div>

View File

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

View File

@ -161,7 +161,7 @@ export function LiveSerpPreview() {
<Button <Button
variant="outline" variant="outline"
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10" 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 Get notified on changes
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />

View File

@ -39,9 +39,9 @@ export function PolicyDemoVisual() {
<motion.div <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" 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={{ animate={{
borderColor: phase === 1 ? '#ef4444' : '#27272a', borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
boxShadow: phase === 1 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)' : '0 1px 3px rgba(0,0,0,0.2)'
}} }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
@ -63,10 +63,10 @@ export function PolicyDemoVisual() {
> >
<motion.p <motion.p
animate={{ 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', paddingLeft: phase === 1 ? '4px' : '0px',
paddingRight: 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 fontWeight: phase === 1 ? 600 : 400
}} }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
@ -91,7 +91,7 @@ export function PolicyDemoVisual() {
initial={{ scaleX: 0 }} initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.4 }} 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> </motion.div>
@ -114,7 +114,7 @@ export function PolicyDemoVisual() {
+18 words +18 words
</span> </span>
<span className="flex items-center gap-1"> <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 -7 words
</span> </span>
</div> </div>
@ -128,16 +128,16 @@ export function PolicyDemoVisual() {
initial={{ opacity: 0, y: 5, scale: 0.9 }} initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.5 }} 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} /> <Check className="h-3 w-3" strokeWidth={3} />
</div> </div>
<div className="flex-1"> <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 Audit trail saved
</div> </div>
<div className="text-[8px] text-red-500/80"> <div className="text-[8px] text-[hsl(var(--burgundy))]/80">
Snapshot archived for compliance Snapshot archived for compliance
</div> </div>
</div> </div>

View File

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

View File

@ -5,7 +5,11 @@ import { useState } from 'react'
import { Check, ArrowRight, Loader2 } from 'lucide-react' import { Check, ArrowRight, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
export function WaitlistForm() { interface WaitlistFormProps {
id?: string
}
export function WaitlistForm({ id }: WaitlistFormProps) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = 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" 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))]"> <span className="text-sm font-bold text-[hsl(var(--burgundy))]">
🎉 Early access: 50% off for 6 months 🎉 Early access
</span> </span>
</motion.div> </motion.div>
</motion.div> </motion.div>
@ -170,65 +174,69 @@ export function WaitlistForm() {
return ( return (
<motion.form <motion.form
id={id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="max-w-md mx-auto" className="max-w-md mx-auto"
> >
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col gap-3">
{/* Email Input */} <div className="flex flex-col sm:flex-row gap-3">
<motion.div {/* Email Input */}
className="flex-1 relative" <motion.div
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}} className="flex-1 relative"
transition={{ duration: 0.4 }} >
> <input
<input type="email"
type="email" value={email}
value={email} onChange={(e) => {
onChange={(e) => { setEmail(e.target.value)
setEmail(e.target.value) setError('')
setError('') }}
}} placeholder="Enter your email"
placeholder="Enter your email" disabled={isSubmitting}
disabled={isSubmitting} className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
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-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' : '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`} } disabled:opacity-50 disabled:cursor-not-allowed`}
/> />
<AnimatePresence> </motion.div>
{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>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
disabled={isSubmitting || !email} disabled={isSubmitting || !email}
size="lg" size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap" className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> <Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining... Joining...
</> </>
) : ( ) : (
<> <>
Reserve Your Spot Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" /> <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> </div>
{/* Trust Signals Below Form */} {/* 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 Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query' 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 // Default to stored user plan from localStorage if API fails or is loading
const getStoredPlan = () => { const getStoredPlan = () => {
if (!mounted) return 'free'
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
try { try {
const userStr = localStorage.getItem('user'); const userStr = localStorage.getItem('user');
@ -98,8 +105,8 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
} }
// Capitalize plan name // Capitalize plan name
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() + const currentPlan = settingsData?.plan || getStoredPlan() || 'free'
(settingsData?.plan || getStoredPlan() || 'free').slice(1); const planName = currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1);
// Determine badge color // Determine badge color
const getBadgeVariant = (plan: string) => { 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; return response.data;
}, },
check: async (id: string) => { check: async (id: string, type: 'all' | 'content' | 'seo' = 'all') => {
const response = await api.post(`/monitors/${id}/check`); const response = await api.post(`/monitors/${id}/check`, { type });
return response.data; return response.data;
}, },
@ -103,6 +103,13 @@ export const monitorAPI = {
return response.data; 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) => { snapshot: async (id: string, snapshotId: string) => {
const response = await api.get(`/monitors/${id}/history/${snapshotId}`); const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
return response.data; return response.data;

View File

@ -4,6 +4,12 @@ export interface Monitor {
name: string name: string
frequency: number frequency: number
status: 'active' | 'paused' | 'error' 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_checked_at?: string
last_changed_at?: string last_changed_at?: string
consecutive_errors: number consecutive_errors: number

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,10 @@
"clsx": "^2.1.0", "clsx": "^2.1.0",
"date-fns": "^3.0.6", "date-fns": "^3.0.6",
"framer-motion": "^12.27.0", "framer-motion": "^12.27.0",
"jimp": "^1.6.0",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
"next": "14.0.4", "next": "14.0.4",
"posthog-js": "^1.331.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0", "react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.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")