This commit is contained in:
knuthtimo-lab 2026-01-19 08:32:44 +01:00
parent b4f6a83da0
commit 818779ab07
125 changed files with 32456 additions and 21017 deletions

View File

@ -0,0 +1,9 @@
---
active: true
iteration: 1
max_iterations: 0
completion_promise: null
started_at: "2026-01-17T14:40:37Z"
---
Implement website monitor features in priority order:

View File

@ -30,3 +30,6 @@ MAX_MONITORS_BUSINESS=200
MIN_FREQUENCY_FREE=60
MIN_FREQUENCY_PRO=5
MIN_FREQUENCY_BUSINESS=1
# AI Summary (OpenAI)
OPENAI_API_KEY=your-openai-api-key-here

View File

@ -17,9 +17,11 @@
"diff": "^5.1.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.8",
"openai": "^6.16.0",
"pg": "^8.11.3",
"zod": "^3.22.4"
},
@ -5210,6 +5212,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -5941,6 +5961,15 @@
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -7317,6 +7346,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.16.0.tgz",
"integrity": "sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -8948,6 +8998,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -11,39 +11,45 @@
"test": "jest",
"lint": "eslint src --ext .ts"
},
"keywords": ["website", "monitor", "change-detection"],
"keywords": [
"website",
"monitor",
"change-detection"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"bullmq": "^5.1.0",
"ioredis": "^5.3.2",
"axios": "^1.6.5",
"bcryptjs": "^2.4.3",
"bullmq": "^5.1.0",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"diff": "^5.1.0",
"zod": "^3.22.4",
"nodemailer": "^6.9.8"
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.8",
"openai": "^6.16.0",
"pg": "^8.11.3",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/diff": "^5.0.9",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/jsonwebtoken": "^9.0.5",
"@types/pg": "^8.10.9",
"@types/node": "^20.10.6",
"@types/nodemailer": "^6.4.14",
"@types/diff": "^5.0.9",
"typescript": "^5.3.3",
"tsx": "^4.7.0",
"@types/pg": "^8.10.9",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"@types/jest": "^29.5.11"
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,32 @@
import 'dotenv/config';
import { query } from '../src/db';
import { fetchPage } from '../src/services/fetcher';
async function run() {
try {
const result = await query('SELECT * FROM monitors WHERE url LIKE $1', ['%3002%']);
const testMonitor = result.rows[0];
if (testMonitor) {
console.log(`Found monitor: "${testMonitor.name}"`);
console.log(`URL in DB: "${testMonitor.url}"`);
console.log(`URL length: ${testMonitor.url.length}`);
console.log('Testing fetchPage for DB URL...');
const result = await fetchPage(testMonitor.url);
console.log('Result:', {
status: result.status,
byteLength: result.html.length,
error: result.error
});
} else {
console.log('No monitor found with port 3002');
}
} catch (err) {
console.error('Error:', err);
} finally {
process.exit();
}
}
run();

View File

@ -0,0 +1,48 @@
import { calculateImportanceScore } from '../src/services/importance';
async function run() {
console.log('Testing Importance Score Calculation');
const testCases = [
{
name: 'Case 1: 100% change, short content, main content',
factors: {
changePercentage: 100,
keywordMatches: 0,
isMainContent: true,
isRecurringPattern: false,
contentLength: 200
}
},
{
name: 'Case 2: 100% change, short content, NOT main content',
factors: {
changePercentage: 100,
keywordMatches: 0,
isMainContent: false,
isRecurringPattern: false,
contentLength: 200
}
},
{
name: 'Case 3: 0.1% change, short content',
factors: {
changePercentage: 0.1,
keywordMatches: 0,
isMainContent: true,
isRecurringPattern: false,
contentLength: 50
}
}
];
for (const test of testCases) {
const score = calculateImportanceScore(test.factors);
console.log(`\n${test.name}:`);
console.log(`Factors:`, test.factors);
console.log(`Score: ${score}`);
}
}
run();

View File

@ -0,0 +1,19 @@
import 'dotenv/config';
import { query } from '../src/db';
async function run() {
console.log('Migrating database: Adding importance_score to snapshots table...');
try {
await query(`
ALTER TABLE snapshots
ADD COLUMN IF NOT EXISTS importance_score INTEGER DEFAULT 0;
`);
console.log('Migration successful: importance_score column added.');
} catch (err) {
console.error('Migration failed:', err);
}
process.exit();
}
run();

70
backend/src/config.ts Normal file
View File

@ -0,0 +1,70 @@
import { UserPlan } from './types';
/**
* Plan-based limits and configuration
*/
export const PLAN_LIMITS = {
free: {
retentionDays: 7,
maxMonitors: 3,
minFrequency: 60, // minutes
features: ['email_alerts', 'basic_noise_filtering'],
},
pro: {
retentionDays: 90,
maxMonitors: 20,
minFrequency: 5,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export'],
},
business: {
retentionDays: 365,
maxMonitors: 100,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members'],
},
enterprise: {
retentionDays: 730, // 2 years
maxMonitors: Infinity,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members', 'custom_integrations', 'sla'],
},
} as const;
/**
* Get the retention period in days for a user plan
*/
export function getRetentionDays(plan: UserPlan): number {
return PLAN_LIMITS[plan]?.retentionDays || PLAN_LIMITS.free.retentionDays;
}
/**
* Get the maximum number of monitors for a user plan
*/
export function getMaxMonitors(plan: UserPlan): number {
return PLAN_LIMITS[plan]?.maxMonitors || PLAN_LIMITS.free.maxMonitors;
}
/**
* Check if a plan has a specific feature
*/
export function hasFeature(plan: UserPlan, feature: string): boolean {
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
return planConfig.features.includes(feature as any);
}
/**
* Webhook retry configuration
*/
export const WEBHOOK_CONFIG = {
maxRetries: 3,
retryDelayMs: 1000,
timeoutMs: 10000,
};
/**
* App configuration
*/
export const APP_CONFIG = {
appUrl: process.env.APP_URL || 'http://localhost:3000',
emailFrom: process.env.EMAIL_FROM || 'noreply@websitemonitor.com',
};

View File

@ -1,6 +1,31 @@
import { Pool, QueryResult } from 'pg';
import { Pool, QueryResult, QueryResultRow } from 'pg';
import { User, Monitor, Snapshot, Alert } from '../types';
// Convert snake_case database keys to camelCase TypeScript properties
function toCamelCase<T>(obj: any): T {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(item => toCamelCase<any>(item)) as T;
if (typeof obj !== 'object') return obj;
const result: any = {};
for (const key in obj) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
let value = obj[key];
// Parse JSON fields that are stored as strings in the database
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
// Keep as-is if parsing fails
}
}
result[camelKey] = value;
}
return result as T;
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
@ -13,7 +38,7 @@ pool.on('error', (err) => {
process.exit(-1);
});
export const query = async <T = any>(
export const query = async <T extends QueryResultRow = any>(
text: string,
params?: any[]
): Promise<QueryResult<T>> => {
@ -34,27 +59,27 @@ export const getClient = () => pool.connect();
export const db = {
users: {
async create(email: string, passwordHash: string): Promise<User> {
const result = await query<User>(
const result = await query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
[email, passwordHash]
);
return result.rows[0];
return toCamelCase<User>(result.rows[0]);
},
async findById(id: string): Promise<User | null> {
const result = await query<User>(
const result = await query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
},
async findByEmail(email: string): Promise<User | null> {
const result = await query<User>(
const result = await query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
},
async update(id: string, updates: Partial<User>): Promise<User | null> {
@ -72,11 +97,71 @@ export const db = {
async updateLastLogin(id: string): Promise<void> {
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
},
async updatePassword(id: string, passwordHash: string): Promise<void> {
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]);
},
async updateNotificationSettings(
id: string,
settings: {
emailEnabled?: boolean;
webhookUrl?: string | null;
webhookEnabled?: boolean;
slackWebhookUrl?: string | null;
slackEnabled?: boolean;
}
): Promise<void> {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (settings.emailEnabled !== undefined) {
updates.push(`email_enabled = $${paramIndex++}`);
values.push(settings.emailEnabled);
}
if (settings.webhookUrl !== undefined) {
updates.push(`webhook_url = $${paramIndex++}`);
values.push(settings.webhookUrl);
}
if (settings.webhookEnabled !== undefined) {
updates.push(`webhook_enabled = $${paramIndex++}`);
values.push(settings.webhookEnabled);
}
if (settings.slackWebhookUrl !== undefined) {
updates.push(`slack_webhook_url = $${paramIndex++}`);
values.push(settings.slackWebhookUrl);
}
if (settings.slackEnabled !== undefined) {
updates.push(`slack_enabled = $${paramIndex++}`);
values.push(settings.slackEnabled);
}
if (updates.length > 0) {
values.push(id);
await query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
values
);
}
},
async delete(id: string): Promise<boolean> {
const result = await query('DELETE FROM users WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
},
async verifyEmail(email: string): Promise<void> {
await query(
'UPDATE users SET email_verified = true, email_verified_at = NOW() WHERE email = $1',
[email]
);
},
},
monitors: {
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
const result = await query<Monitor>(
const result = await query(
`INSERT INTO monitors (
user_id, url, name, frequency, status, element_selector,
ignore_rules, keyword_rules
@ -92,23 +177,23 @@ export const db = {
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
]
);
return result.rows[0];
return toCamelCase<Monitor>(result.rows[0]);
},
async findById(id: string): Promise<Monitor | null> {
const result = await query<Monitor>(
const result = await query(
'SELECT * FROM monitors WHERE id = $1',
[id]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
},
async findByUserId(userId: string): Promise<Monitor[]> {
const result = await query<Monitor>(
const result = await query(
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return result.rows;
return result.rows.map(row => toCamelCase<Monitor>(row));
},
async countByUserId(userId: string): Promise<number> {
@ -120,11 +205,11 @@ export const db = {
},
async findActiveMonitors(): Promise<Monitor[]> {
const result = await query<Monitor>(
const result = await query(
'SELECT * FROM monitors WHERE status = $1',
['active']
);
return result.rows;
return result.rows.map(row => toCamelCase<Monitor>(row));
},
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
@ -148,11 +233,11 @@ export const db = {
if (fields.length === 0) return null;
const result = await query<Monitor>(
const result = await query(
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
[id, ...values]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
},
async delete(id: string): Promise<boolean> {
@ -163,12 +248,12 @@ export const db = {
async updateLastChecked(id: string, changed: boolean): Promise<void> {
if (changed) {
await query(
'UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0 WHERE id = $1',
"UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
[id]
);
} else {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0 WHERE id = $1',
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
[id]
);
}
@ -176,7 +261,7 @@ export const db = {
async incrementErrors(id: string): Promise<void> {
await query(
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1 WHERE id = $1',
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1, status = CASE WHEN consecutive_errors >= 0 THEN 'error' ELSE status END WHERE id = $1",
[id]
);
},
@ -184,11 +269,12 @@ export const db = {
snapshots: {
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
const result = await query<Snapshot>(
const result = await query(
`INSERT INTO snapshots (
monitor_id, html_content, text_content, content_hash, screenshot_url,
http_status, response_time, changed, change_percentage, error_message
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
http_status, response_time, changed, change_percentage, error_message,
importance_score, summary
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[
data.monitorId,
data.htmlContent,
@ -200,33 +286,35 @@ export const db = {
data.changed,
data.changePercentage || null,
data.errorMessage || null,
data.importanceScore ?? 0,
data.summary || null,
]
);
return result.rows[0];
return toCamelCase<Snapshot>(result.rows[0]);
},
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
const result = await query<Snapshot>(
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
[monitorId, limit]
);
return result.rows;
return result.rows.map(row => toCamelCase<Snapshot>(row));
},
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
const result = await query(
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
[monitorId]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
},
async findById(id: string): Promise<Snapshot | null> {
const result = await query<Snapshot>(
const result = await query(
'SELECT * FROM snapshots WHERE id = $1',
[id]
);
return result.rows[0] || null;
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
},
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
@ -242,11 +330,20 @@ export const db = {
[monitorId, keepCount]
);
},
async deleteOldSnapshotsByAge(monitorId: string, retentionDays: number): Promise<void> {
await query(
`DELETE FROM snapshots
WHERE monitor_id = $1
AND created_at < NOW() - INTERVAL '1 day' * $2`,
[monitorId, retentionDays]
);
},
},
alerts: {
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
const result = await query<Alert>(
const result = await query(
`INSERT INTO alerts (
monitor_id, snapshot_id, user_id, type, title, summary, channels
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
@ -260,15 +357,15 @@ export const db = {
JSON.stringify(data.channels),
]
);
return result.rows[0];
return toCamelCase<Alert>(result.rows[0]);
},
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
const result = await query<Alert>(
const result = await query(
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows;
return result.rows.map(row => toCamelCase<Alert>(row));
},
async markAsDelivered(id: string): Promise<void> {
@ -278,6 +375,63 @@ export const db = {
async markAsRead(id: string): Promise<void> {
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
},
async updateChannels(id: string, channels: string[]): Promise<void> {
await query('UPDATE alerts SET channels = $1 WHERE id = $2', [JSON.stringify(channels), id]);
},
},
webhookLogs: {
async create(data: {
userId: string;
monitorId?: string;
alertId?: string;
webhookType: 'webhook' | 'slack';
url: string;
payload?: any;
statusCode?: number;
responseBody?: string;
success: boolean;
errorMessage?: string;
attempt?: number;
}): Promise<{ id: string }> {
const result = await query(
`INSERT INTO webhook_logs (
user_id, monitor_id, alert_id, webhook_type, url, payload,
status_code, response_body, success, error_message, attempt
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
[
data.userId,
data.monitorId || null,
data.alertId || null,
data.webhookType,
data.url,
data.payload ? JSON.stringify(data.payload) : null,
data.statusCode || null,
data.responseBody || null,
data.success,
data.errorMessage || null,
data.attempt || 1,
]
);
return { id: result.rows[0].id };
},
async findByUserId(userId: string, limit = 100): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
async findFailedByUserId(userId: string, limit = 50): Promise<any[]> {
const result = await query(
'SELECT * FROM webhook_logs WHERE user_id = $1 AND success = false ORDER BY created_at DESC LIMIT $2',
[userId, limit]
);
return result.rows.map(row => toCamelCase<any>(row));
},
},
};

View File

@ -0,0 +1,12 @@
-- Migration: Add notification settings to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_enabled BOOLEAN DEFAULT true,
ADD COLUMN IF NOT EXISTS webhook_url TEXT,
ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT,
ADD COLUMN IF NOT EXISTS slack_enabled BOOLEAN DEFAULT false;
-- Add index for webhook lookups
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;

View File

@ -0,0 +1,8 @@
-- Migration: Add email verification to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP;
-- Add index for quick lookups
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);

View File

@ -0,0 +1,23 @@
-- Migration: Add webhook delivery logs table
-- For tracking webhook/slack delivery attempts and debugging
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
url TEXT NOT NULL,
payload JSONB,
status_code INTEGER,
response_body TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
attempt INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_success ON webhook_logs(success) WHERE success = false;

View File

@ -0,0 +1,11 @@
-- Add summary column to snapshots table
-- This stores human-readable summaries of changes (e.g., "3 text blocks changed, 2 new links added")
ALTER TABLE snapshots
ADD COLUMN summary TEXT;
-- Add index for faster queries when filtering by summary existence
CREATE INDEX idx_snapshots_summary ON snapshots(summary) WHERE summary IS NOT NULL;
-- Comment
COMMENT ON COLUMN snapshots.summary IS 'Human-readable change summary generated by simple HTML parsing or AI';

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations

View File

@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS users (
password_hash VARCHAR(255) NOT NULL,
plan VARCHAR(20) DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'business', 'enterprise')),
stripe_customer_id VARCHAR(255),
email_enabled BOOLEAN DEFAULT true,
webhook_url TEXT,
webhook_enabled BOOLEAN DEFAULT false,
slack_webhook_url TEXT,
slack_enabled BOOLEAN DEFAULT false,
email_verified BOOLEAN DEFAULT false,
email_verified_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
last_login_at TIMESTAMP,
updated_at TIMESTAMP DEFAULT NOW()
@ -14,6 +21,8 @@ CREATE TABLE IF NOT EXISTS users (
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_plan ON users(plan);
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
-- Monitors table
CREATE TABLE IF NOT EXISTS monitors (
@ -91,3 +100,24 @@ CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
CREATE TRIGGER update_monitors_updated_at BEFORE UPDATE ON monitors
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Webhook delivery logs table
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
url TEXT NOT NULL,
payload JSONB,
status_code INTEGER,
response_body TEXT,
success BOOLEAN NOT NULL,
error_message TEXT,
attempt INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);

View File

@ -1,43 +1,52 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import monitorRoutes from './routes/monitors';
import settingsRoutes from './routes/settings';
import { authMiddleware } from './middleware/auth';
// Load environment variables
dotenv.config();
import { apiLimiter, authLimiter } from './middleware/rateLimiter';
import { startWorker, shutdownScheduler, getSchedulerStats } from './services/scheduler';
const app = express();
const PORT = process.env.PORT || 3001;
const PORT = process.env.PORT || 3002;
// Middleware
app.use(cors({
origin: process.env.APP_URL || 'http://localhost:3000',
origin: [process.env.APP_URL || 'http://localhost:3000', 'http://localhost:3020', 'http://localhost:3021'],
credentials: true,
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Apply general rate limiter to all API routes
app.use('/api/', apiLimiter);
// Request logging
app.use((req, res, next) => {
app.use((req, _res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
next();
});
// Health check
app.get('/health', (req, res) => {
app.get('/health', async (_req, res) => {
const schedulerStats = await getSchedulerStats();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
scheduler: schedulerStats,
});
});
import testRoutes from './routes/test';
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/monitors', authMiddleware, monitorRoutes);
app.use('/api/settings', authMiddleware, settingsRoutes);
app.use('/test', testRoutes);
// 404 handler
app.use((req, res) => {
@ -49,7 +58,7 @@ app.use((req, res) => {
});
// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
@ -58,6 +67,10 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
});
});
// Start Bull queue worker
const worker = startWorker();
console.log('📋 Bull queue worker initialized');
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
@ -66,12 +79,16 @@ app.listen(PORT, () => {
});
// Graceful shutdown
process.on('SIGTERM', () => {
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await worker.close();
await shutdownScheduler();
process.exit(0);
});
process.on('SIGINT', () => {
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await worker.close();
await shutdownScheduler();
process.exit(0);
});

View File

@ -37,7 +37,7 @@ export function authMiddleware(
export function optionalAuthMiddleware(
req: AuthRequest,
res: Response,
_res: Response,
next: NextFunction
): void {
try {

View File

@ -0,0 +1,29 @@
import rateLimit from 'express-rate-limit';
// General API rate limit
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: { error: 'rate_limit_exceeded', message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
// Strict rate limit for auth endpoints (prevent brute force)
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 requests per windowMs
message: { error: 'rate_limit_exceeded', message: 'Too many authentication attempts, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins
});
// Moderate rate limit for monitor checks
export const checkLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 20, // Limit each IP to 20 manual checks per 5 minutes
message: { error: 'rate_limit_exceeded', message: 'Too many manual checks, please wait before trying again.' },
standardHeaders: true,
legacyHeaders: false,
});

View File

@ -7,7 +7,12 @@ import {
generateToken,
validateEmail,
validatePassword,
generatePasswordResetToken,
verifyPasswordResetToken,
generateEmailVerificationToken,
verifyEmailVerificationToken,
} from '../utils/auth';
import { sendPasswordResetEmail, sendEmailVerification } from '../services/alerter';
const router = Router();
@ -56,6 +61,17 @@ router.post('/register', async (req: Request, res: Response): Promise<void> => {
const passwordHash = await hashPassword(password);
const user = await db.users.create(email, passwordHash);
// Generate verification token and send email
const verificationToken = generateEmailVerificationToken(email);
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
try {
await sendEmailVerification(email, verificationUrl);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// Continue with registration even if email fails
}
const token = generateToken(user);
res.status(201).json({
@ -64,6 +80,7 @@ router.post('/register', async (req: Request, res: Response): Promise<void> => {
id: user.id,
email: user.email,
plan: user.plan,
emailVerified: user.emailVerified || false,
createdAt: user.createdAt,
},
});
@ -140,4 +157,212 @@ router.post('/login', async (req: Request, res: Response): Promise<void> => {
}
});
// Forgot Password
router.post('/forgot-password', async (req: Request, res: Response): Promise<void> => {
try {
const { email } = z.object({ email: z.string().email() }).parse(req.body);
const user = await db.users.findByEmail(email);
// Always return success to prevent email enumeration attacks
if (!user) {
res.json({ message: 'If that email is registered, you will receive a password reset link' });
return;
}
// Generate reset token
const resetToken = generatePasswordResetToken(email);
// Send reset email
const resetUrl = `${process.env.APP_URL || 'http://localhost:3000'}/reset-password/${resetToken}`;
try {
await sendPasswordResetEmail(user.email, resetUrl);
} catch (emailError) {
console.error('Failed to send password reset email:', emailError);
// Still return success to user, but log the error
}
res.json({ message: 'If that email is registered, you will receive a password reset link' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid email',
details: error.errors,
});
return;
}
console.error('Forgot password error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to process password reset request',
});
}
});
// Reset Password
router.post('/reset-password', async (req: Request, res: Response): Promise<void> => {
try {
const { token, newPassword } = z.object({
token: z.string(),
newPassword: z.string().min(8),
}).parse(req.body);
// Verify token
let email: string;
try {
const decoded = verifyPasswordResetToken(token);
email = decoded.email;
} catch (error) {
res.status(400).json({
error: 'invalid_token',
message: 'Invalid or expired reset token',
});
return;
}
// Validate new password
const passwordValidation = validatePassword(newPassword);
if (!passwordValidation.valid) {
res.status(400).json({
error: 'invalid_password',
message: 'Password does not meet requirements',
details: passwordValidation.errors,
});
return;
}
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
res.status(404).json({
error: 'user_not_found',
message: 'User not found',
});
return;
}
// Update password
const newPasswordHash = await hashPassword(newPassword);
await db.users.updatePassword(user.id, newPasswordHash);
res.json({ message: 'Password reset successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Reset password error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to reset password',
});
}
});
// Verify Email
router.post('/verify-email', async (req: Request, res: Response): Promise<void> => {
try {
const { token } = z.object({ token: z.string() }).parse(req.body);
// Verify token
let email: string;
try {
const decoded = verifyEmailVerificationToken(token);
email = decoded.email;
} catch (error) {
res.status(400).json({
error: 'invalid_token',
message: 'Invalid or expired verification token',
});
return;
}
// Find user
const user = await db.users.findByEmail(email);
if (!user) {
res.status(404).json({
error: 'user_not_found',
message: 'User not found',
});
return;
}
// Check if already verified
if (user.emailVerified) {
res.json({ message: 'Email already verified' });
return;
}
// Mark email as verified
await db.users.verifyEmail(email);
res.json({ message: 'Email verified successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Verify email error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to verify email',
});
}
});
// Resend Verification Email
router.post('/resend-verification', async (req: Request, res: Response): Promise<void> => {
try {
const { email } = z.object({ email: z.string().email() }).parse(req.body);
const user = await db.users.findByEmail(email);
// Always return success to prevent email enumeration
if (!user || user.emailVerified) {
res.json({ message: 'If that email needs verification, a new link has been sent' });
return;
}
// Generate new verification token
const verificationToken = generateEmailVerificationToken(email);
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
try {
await sendEmailVerification(email, verificationUrl);
} catch (emailError) {
console.error('Failed to resend verification email:', emailError);
}
res.json({ message: 'If that email needs verification, a new link has been sent' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid email',
details: error.errors,
});
return;
}
console.error('Resend verification error:', error);
res.status(500).json({
error: 'server_error',
message: 'Failed to resend verification email',
});
}
});
export default router;

View File

@ -2,8 +2,9 @@ import { Router, Response } from 'express';
import { z } from 'zod';
import db from '../db';
import { AuthRequest } from '../middleware/auth';
import { CreateMonitorInput, UpdateMonitorInput } from '../types';
import { checkMonitor } from '../services/monitor';
import { checkLimiter } from '../middleware/rateLimiter';
import { MonitorFrequency, Monitor } from '../types';
import { checkMonitor, scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from '../services/monitor';
const router = Router();
@ -91,7 +92,17 @@ router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
const monitors = await db.monitors.findByUserId(req.user.userId);
res.json({ monitors });
// Attach recent snapshots to each monitor for sparklines
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => {
// Get last 20 snapshots for sparkline
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
return {
...monitor,
recentSnapshots
};
}));
res.json({ monitors: monitorsWithSnapshots });
} catch (error) {
console.error('List monitors error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
@ -135,14 +146,16 @@ router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
const input = createMonitorSchema.parse(req.body);
// Check plan limits
const limits = getPlanLimits(req.user.plan);
// Check plan limits (fetch fresh user data)
const currentUser = await db.users.findById(req.user.userId);
const plan = currentUser?.plan || req.user.plan;
const limits = getPlanLimits(plan);
const currentCount = await db.monitors.countByUserId(req.user.userId);
if (currentCount >= limits.maxMonitors) {
res.status(403).json({
error: 'limit_exceeded',
message: `Your ${req.user.plan} plan allows max ${limits.maxMonitors} monitors`,
message: `Your ${plan} plan allows max ${limits.maxMonitors} monitors`,
});
return;
}
@ -150,7 +163,7 @@ router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
if (input.frequency < limits.minFrequency) {
res.status(403).json({
error: 'invalid_frequency',
message: `Your ${req.user.plan} plan requires minimum ${limits.minFrequency} minute frequency`,
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
});
return;
}
@ -162,13 +175,21 @@ router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
userId: req.user.userId,
url: input.url,
name,
frequency: input.frequency,
frequency: input.frequency as MonitorFrequency,
status: 'active',
elementSelector: input.elementSelector,
ignoreRules: input.ignoreRules,
keywordRules: input.keywordRules,
});
// Schedule recurring checks
try {
await scheduleMonitor(monitor);
console.log(`Monitor ${monitor.id} scheduled successfully`);
} catch (err) {
console.error('Failed to schedule monitor:', err);
}
// Perform first check immediately
checkMonitor(monitor.id).catch((err) =>
console.error('Initial check failed:', err)
@ -214,17 +235,49 @@ router.put('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
// Check frequency limit if being updated
if (input.frequency) {
const limits = getPlanLimits(req.user.plan);
// Fetch fresh user data to get current plan
const currentUser = await db.users.findById(req.user.userId);
const plan = currentUser?.plan || req.user.plan;
const limits = getPlanLimits(plan);
if (input.frequency < limits.minFrequency) {
res.status(403).json({
error: 'invalid_frequency',
message: `Your ${req.user.plan} plan requires minimum ${limits.minFrequency} minute frequency`,
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
});
return;
}
}
const updated = await db.monitors.update(req.params.id, input);
const updateData: Partial<Monitor> = {
...input,
frequency: input.frequency as MonitorFrequency | undefined,
};
const updated = await db.monitors.update(req.params.id, updateData);
if (!updated) {
res.status(500).json({ error: 'update_failed', message: 'Failed to update monitor' });
return;
}
// Reschedule if frequency changed or status changed to/from active
const needsRescheduling =
input.frequency !== undefined ||
(input.status && (input.status === 'active' || monitor.status === 'active'));
if (needsRescheduling) {
try {
if (updated.status === 'active') {
await rescheduleMonitor(updated);
console.log(`Monitor ${updated.id} rescheduled`);
} else {
await unscheduleMonitor(updated.id);
console.log(`Monitor ${updated.id} unscheduled (status: ${updated.status})`);
}
} catch (err) {
console.error('Failed to reschedule monitor:', err);
}
}
res.json({ monitor: updated });
} catch (error) {
@ -262,6 +315,14 @@ router.delete('/:id', async (req: AuthRequest, res: Response): Promise<void> =>
return;
}
// Unschedule before deleting
try {
await unscheduleMonitor(req.params.id);
console.log(`Monitor ${req.params.id} unscheduled before deletion`);
} catch (err) {
console.error('Failed to unschedule monitor:', err);
}
await db.monitors.delete(req.params.id);
res.json({ message: 'Monitor deleted successfully' });
@ -272,7 +333,7 @@ router.delete('/:id', async (req: AuthRequest, res: Response): Promise<void> =>
});
// Trigger manual check
router.post('/:id/check', async (req: AuthRequest, res: Response): Promise<void> => {
router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
@ -291,10 +352,34 @@ router.post('/:id/check', async (req: AuthRequest, res: Response): Promise<void>
return;
}
// Trigger check (don't wait for it)
checkMonitor(monitor.id).catch((err) => console.error('Manual check failed:', err));
// Await the check so user gets immediate feedback
try {
await checkMonitor(monitor.id);
res.json({ message: 'Check triggered successfully' });
// Get the latest snapshot to return to the user
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
const updatedMonitor = await db.monitors.findById(monitor.id);
res.json({
message: 'Check completed successfully',
monitor: updatedMonitor,
snapshot: latestSnapshot ? {
id: latestSnapshot.id,
changed: latestSnapshot.changed,
changePercentage: latestSnapshot.changePercentage,
httpStatus: latestSnapshot.httpStatus,
responseTime: latestSnapshot.responseTime,
createdAt: latestSnapshot.createdAt,
errorMessage: latestSnapshot.errorMessage,
} : null,
});
} catch (checkError: any) {
console.error('Check failed:', checkError);
res.status(500).json({
error: 'check_failed',
message: checkError.message || 'Failed to check monitor'
});
}
} catch (error) {
console.error('Trigger check error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to trigger check' });
@ -361,11 +446,169 @@ router.get(
}
res.json({ snapshot });
}catch (error) {
} catch (error) {
console.error('Get snapshot error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to get snapshot' });
}
}
);
// Export monitor audit trail (JSON or CSV)
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
// Check if user has export feature (PRO+)
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Allow export for all users for now, but in production check plan
// if (!hasFeature(user.plan, 'audit_export')) {
// res.status(403).json({ error: 'forbidden', message: 'Export feature requires Pro plan' });
// 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 format = (req.query.format as string)?.toLowerCase() || 'json';
const fromDate = req.query.from ? new Date(req.query.from as string) : undefined;
const toDate = req.query.to ? new Date(req.query.to as string) : undefined;
// Get all snapshots (up to 1000)
let snapshots = await db.snapshots.findByMonitorId(monitor.id, 1000);
// Filter by date range if provided
if (fromDate) {
snapshots = snapshots.filter(s => new Date(s.createdAt) >= fromDate);
}
if (toDate) {
snapshots = snapshots.filter(s => new Date(s.createdAt) <= toDate);
}
// Get alerts for this monitor
const allAlerts = await db.alerts.findByUserId(req.user.userId, 1000);
const monitorAlerts = allAlerts.filter(a => a.monitorId === monitor.id);
// Filter alerts by date range if provided
let filteredAlerts = monitorAlerts;
if (fromDate) {
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) >= fromDate);
}
if (toDate) {
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) <= toDate);
}
const exportData = {
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
frequency: monitor.frequency,
status: monitor.status,
createdAt: monitor.createdAt,
},
exportedAt: new Date().toISOString(),
dateRange: {
from: fromDate?.toISOString() || 'start',
to: toDate?.toISOString() || 'now',
},
summary: {
totalChecks: snapshots.length,
changesDetected: snapshots.filter(s => s.changed).length,
errorsDetected: snapshots.filter(s => s.errorMessage).length,
totalAlerts: filteredAlerts.length,
},
checks: snapshots.map(s => ({
id: s.id,
timestamp: s.createdAt,
changed: s.changed,
changePercentage: s.changePercentage,
httpStatus: s.httpStatus,
responseTime: s.responseTime,
errorMessage: s.errorMessage,
})),
alerts: filteredAlerts.map(a => ({
id: a.id,
type: a.type,
title: a.title,
summary: a.summary,
channels: a.channels,
createdAt: a.createdAt,
deliveredAt: a.deliveredAt,
})),
};
if (format === 'csv') {
// Generate CSV
const csvLines: string[] = [];
// Header
csvLines.push('Type,Timestamp,Changed,Change %,HTTP Status,Response Time (ms),Error,Alert Type,Alert Title');
// Checks
for (const check of exportData.checks) {
csvLines.push([
'check',
check.timestamp,
check.changed ? 'true' : 'false',
check.changePercentage?.toFixed(2) || '',
check.httpStatus,
check.responseTime,
`"${(check.errorMessage || '').replace(/"/g, '""')}"`,
'',
'',
].join(','));
}
// Alerts
for (const alert of exportData.alerts) {
csvLines.push([
'alert',
alert.createdAt,
'',
'',
'',
'',
'',
alert.type,
`"${(alert.title || '').replace(/"/g, '""')}"`,
].join(','));
}
const csv = csvLines.join('\n');
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.csv`;
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(csv);
} else {
// JSON format
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.json`;
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.json(exportData);
}
} catch (error) {
console.error('Export error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to export audit trail' });
}
});
export default router;

View File

@ -0,0 +1,185 @@
import { Router, Response } from 'express';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import db from '../db';
import { AuthRequest } from '../middleware/auth';
const router = Router();
const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
const updateNotificationsSchema = z.object({
emailEnabled: z.boolean().optional(),
webhookUrl: z.string().url().optional().nullable(),
webhookEnabled: z.boolean().optional(),
slackWebhookUrl: z.string().url().optional().nullable(),
slackEnabled: z.boolean().optional(),
});
// Get user settings
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Return user settings (exclude password hash)
res.json({
settings: {
email: user.email,
plan: user.plan,
stripeCustomerId: user.stripeCustomerId,
emailEnabled: user.emailEnabled ?? true,
webhookUrl: user.webhookUrl,
webhookEnabled: user.webhookEnabled ?? false,
slackWebhookUrl: user.slackWebhookUrl,
slackEnabled: user.slackEnabled ?? false,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
});
} catch (error) {
console.error('Get settings error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to get settings' });
}
});
// Change password
router.post('/change-password', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const input = changePasswordSchema.parse(req.body);
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Verify current password
const isValidPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({ error: 'invalid_password', message: 'Current password is incorrect' });
return;
}
// Hash new password
const newPasswordHash = await bcrypt.hash(input.newPassword, 10);
// Update password
await db.users.updatePassword(req.user.userId, newPasswordHash);
res.json({ message: 'Password changed successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Change password error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to change password' });
}
});
// Update notification preferences
router.put('/notifications', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const input = updateNotificationsSchema.parse(req.body);
await db.users.updateNotificationSettings(req.user.userId, {
emailEnabled: input.emailEnabled,
webhookUrl: input.webhookUrl,
webhookEnabled: input.webhookEnabled,
slackWebhookUrl: input.slackWebhookUrl,
slackEnabled: input.slackEnabled,
});
res.json({ message: 'Notification settings updated successfully' });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({
error: 'validation_error',
message: 'Invalid input',
details: error.errors,
});
return;
}
console.error('Update notifications error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to update notifications' });
}
});
// Delete account
router.delete('/account', async (req: AuthRequest, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
return;
}
const { password } = req.body;
if (!password) {
res.status(400).json({ error: 'validation_error', message: 'Password is required' });
return;
}
const user = await db.users.findById(req.user.userId);
if (!user) {
res.status(404).json({ error: 'not_found', message: 'User not found' });
return;
}
// Verify password before deletion
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
res.status(401).json({ error: 'invalid_password', message: 'Password is incorrect' });
return;
}
// Delete all user's monitors (cascades to snapshots and alerts)
const monitors = await db.monitors.findByUserId(req.user.userId);
for (const monitor of monitors) {
await db.monitors.delete(monitor.id);
}
// Delete user
await db.users.delete(req.user.userId);
res.json({ message: 'Account deleted successfully' });
} catch (error) {
console.error('Delete account error:', error);
res.status(500).json({ error: 'server_error', message: 'Failed to delete account' });
}
});
export default router;

View File

@ -0,0 +1,65 @@
import { Router, Response } from 'express';
const router = Router();
router.get('/dynamic', (_req, res) => {
const now = new Date();
const timeString = now.toLocaleTimeString();
const randomValue = Math.floor(Math.random() * 1000);
// Toggle status based on seconds (even/odd) to guarantee change
const isNormal = now.getSeconds() % 2 === 0;
const statusMessage = isNormal
? "System Status: NORMAL - Everything is running smoothly."
: "System Status: WARNING - High load detected on server node!";
const statusColor = isNormal ? "green" : "red";
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Dynamic Test Page</title>
<style>
body { font-family: sans-serif; padding: 20px; }
.time { font-size: 2em; color: #0066cc; }
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; }
</style>
</head>
<body>
<h1>Website Monitor Test</h1>
<div class="status">${statusMessage}</div>
<div class="content">
<p>Current Time: <span class="time">${timeString}</span></p>
<p>Random Value: <span class="random">${randomValue}</span></p>
<p>This page content flips every second to simulate a real website change.</p>
<div style="background: #f0f9ff; padding: 15px; margin-top: 20px; border-left: 4px solid #0066cc;">
<h3>New Feature Update</h3>
<p>We have deployed a new importance scoring update!</p>
</div>
</div>
</body>
</html>
`;
res.send(html);
});
// Test endpoint that returns a 404 error for testing incident display
router.get('/error', (_req, res: Response) => {
res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>404 Not Found</title>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>This page intentionally returns a 404 error for testing.</p>
</body>
</html>
`);
});
export default router;

View File

@ -1,7 +1,8 @@
import nodemailer from 'nodemailer';
import { Monitor, User, Snapshot } from '../types';
import * as nodemailer from 'nodemailer';
import { Monitor, User, Snapshot, AlertChannel } from '../types';
import { KeywordMatch } from './differ';
import db from '../db';
import { APP_CONFIG, WEBHOOK_CONFIG, hasFeature } from '../config';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
@ -13,8 +14,332 @@ const transporter = nodemailer.createTransport({
},
});
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@websitemonitor.com';
// ============================================
// Slack Integration
// ============================================
interface SlackMessage {
title: string;
text: string;
url?: string;
color?: 'good' | 'warning' | 'danger';
}
/**
* Send a notification to a Slack webhook
*/
export async function sendSlackNotification(
webhookUrl: string,
message: SlackMessage,
userId: string,
monitorId?: string,
alertId?: string
): Promise<boolean> {
const payload = {
attachments: [
{
color: message.color || '#007bff',
title: message.title,
title_link: message.url,
text: message.text,
footer: 'Website Monitor',
ts: Math.floor(Date.now() / 1000),
},
],
};
let attempt = 1;
let lastError: string | undefined;
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
const responseBody = await response.text();
// Log the attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'slack',
url: webhookUrl,
payload,
statusCode: response.status,
responseBody: responseBody.substring(0, 1000),
success: response.ok,
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
attempt,
});
if (response.ok) {
console.log(`[Slack] Notification sent successfully`);
return true;
}
lastError = `HTTP ${response.status}: ${responseBody}`;
} catch (error: any) {
lastError = error.message || 'Unknown error';
// Log failed attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'slack',
url: webhookUrl,
payload,
success: false,
errorMessage: lastError,
attempt,
});
}
if (attempt < WEBHOOK_CONFIG.maxRetries) {
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
}
attempt++;
}
console.error(`[Slack] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
return false;
}
// ============================================
// Webhook Integration
// ============================================
interface WebhookPayload {
event: 'change' | 'error' | 'keyword';
monitor: {
id: string;
name: string;
url: string;
};
details: {
changePercentage?: number;
errorMessage?: string;
keywordMatch?: KeywordMatch;
};
timestamp: string;
viewUrl: string;
}
/**
* Send a notification to a generic webhook
*/
export async function sendWebhookNotification(
webhookUrl: string,
payload: WebhookPayload,
userId: string,
monitorId?: string,
alertId?: string
): Promise<boolean> {
let attempt = 1;
let lastError: string | undefined;
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'WebsiteMonitor/1.0',
'X-Webhook-Event': payload.event,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
clearTimeout(timeout);
let responseBody = '';
try {
responseBody = await response.text();
} catch {
// Ignore response body parsing errors
}
// Log the attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'webhook',
url: webhookUrl,
payload: payload as any,
statusCode: response.status,
responseBody: responseBody.substring(0, 1000),
success: response.ok,
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
attempt,
});
if (response.ok) {
console.log(`[Webhook] Notification sent successfully`);
return true;
}
lastError = `HTTP ${response.status}`;
} catch (error: any) {
lastError = error.message || 'Unknown error';
// Log failed attempt
await db.webhookLogs.create({
userId,
monitorId,
alertId,
webhookType: 'webhook',
url: webhookUrl,
payload: payload as any,
success: false,
errorMessage: lastError,
attempt,
});
}
if (attempt < WEBHOOK_CONFIG.maxRetries) {
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
}
attempt++;
}
console.error(`[Webhook] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
return false;
}
// ============================================
// Unified Alert Dispatcher
// ============================================
interface AlertData {
title: string;
summary: string;
htmlContent: string;
viewUrl: string;
color?: 'good' | 'warning' | 'danger';
changePercentage?: number;
errorMessage?: string;
keywordMatch?: KeywordMatch;
}
/**
* Dispatch an alert to all configured channels for a user
*/
async function dispatchAlert(
user: User,
monitor: Monitor,
snapshot: Snapshot | null,
alertType: 'change' | 'error' | 'keyword',
data: AlertData
): Promise<AlertChannel[]> {
const usedChannels: AlertChannel[] = [];
// Create alert record first
let alertId: string | undefined;
if (snapshot) {
const alert = await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: alertType,
title: data.title,
summary: data.summary,
channels: ['email'], // Will be updated after dispatch
});
alertId = alert.id;
}
// 1. Email (always available)
if (user.emailEnabled !== false) {
try {
await transporter.sendMail({
from: APP_CONFIG.emailFrom,
to: user.email,
subject: data.title,
html: data.htmlContent,
});
usedChannels.push('email');
console.log(`[Alert] Email sent to ${user.email}`);
} catch (error) {
console.error(`[Alert] Failed to send email:`, error);
}
}
// 2. Slack (PRO+ feature)
if (user.slackEnabled && user.slackWebhookUrl && hasFeature(user.plan, 'slack_integration')) {
const success = await sendSlackNotification(
user.slackWebhookUrl,
{
title: data.title,
text: data.summary,
url: data.viewUrl,
color: data.color || 'warning',
},
user.id,
monitor.id,
alertId
);
if (success) {
usedChannels.push('slack');
}
}
// 3. Webhook (PRO+ feature)
if (user.webhookEnabled && user.webhookUrl && hasFeature(user.plan, 'webhook_integration')) {
const webhookPayload: WebhookPayload = {
event: alertType,
monitor: {
id: monitor.id,
name: monitor.name,
url: monitor.url,
},
details: {
changePercentage: data.changePercentage,
errorMessage: data.errorMessage,
keywordMatch: data.keywordMatch,
},
timestamp: new Date().toISOString(),
viewUrl: data.viewUrl,
};
const success = await sendWebhookNotification(
user.webhookUrl,
webhookPayload,
user.id,
monitor.id,
alertId
);
if (success) {
usedChannels.push('webhook');
}
}
// Update alert with used channels
if (alertId && usedChannels.length > 0) {
await db.alerts.updateChannels(alertId, usedChannels);
await db.alerts.markAsDelivered(alertId);
}
return usedChannels;
}
// ============================================
// Alert Functions (Public API)
// ============================================
export async function sendChangeAlert(
monitor: Monitor,
@ -22,53 +347,39 @@ export async function sendChangeAlert(
snapshot: Snapshot,
changePercentage: number
): Promise<void> {
try {
const diffUrl = `${APP_URL}/monitors/${monitor.id}/history/${snapshot.id}`;
const diffUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}/history/${snapshot.id}`;
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Change detected: ${monitor.name}`,
html: `
<h2>Change Detected</h2>
<p>A change was detected on your monitored page: <strong>${monitor.name}</strong></p>
const htmlContent = `
<h2>Change Detected</h2>
<p>A change was detected on your monitored page: <strong>${monitor.name}</strong></p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Change Percentage:</strong> ${changePercentage.toFixed(2)}%</p>
<p><strong>Detected At:</strong> ${new Date().toLocaleString()}</p>
</div>
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
${snapshot.summary ? `<p><strong>What Changed:</strong> ${snapshot.summary}</p>` : ''}
<p><strong>Change Percentage:</strong> ${changePercentage.toFixed(2)}%</p>
<p><strong>Detected At:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>
<a href="${diffUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Changes
</a>
</p>
<p>
<a href="${diffUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Changes
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
You're receiving this because you set up monitoring for this page.
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`,
};
<p style="color: #666; font-size: 12px; margin-top: 30px;">
You're receiving this because you set up monitoring for this page.
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
await transporter.sendMail(mailOptions);
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'change',
title: `Change detected: ${monitor.name}`,
summary: `${changePercentage.toFixed(2)}% of the page changed`,
channels: ['email'],
});
console.log(`[Alert] Change alert sent to ${user.email} for monitor ${monitor.name}`);
} catch (error) {
console.error('[Alert] Failed to send change alert:', error);
}
await dispatchAlert(user, monitor, snapshot, 'change', {
title: `Change detected: ${monitor.name}`,
summary: snapshot.summary || `${changePercentage.toFixed(2)}% of the page changed`,
htmlContent,
viewUrl: diffUrl,
color: changePercentage > 50 ? 'danger' : changePercentage > 10 ? 'warning' : 'good',
changePercentage,
});
}
export async function sendErrorAlert(
@ -76,134 +387,196 @@ export async function sendErrorAlert(
user: User,
errorMessage: string
): Promise<void> {
try {
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
const monitorUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}`;
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Error monitoring: ${monitor.name}`,
html: `
<h2>Monitoring Error</h2>
<p>We encountered an error while monitoring: <strong>${monitor.name}</strong></p>
const htmlContent = `
<h2>Monitoring Error</h2>
<p>We encountered an error while monitoring: <strong>${monitor.name}</strong></p>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Error:</strong> ${errorMessage}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Error:</strong> ${errorMessage}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>We'll keep trying to check this page. If the problem persists, you may want to verify the URL or check if the site is blocking automated requests.</p>
<p>We'll keep trying to check this page. If the problem persists, you may want to verify the URL or check if the site is blocking automated requests.</p>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor Settings
</a>
</p>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor Settings
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`,
};
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
await transporter.sendMail(mailOptions);
// Create snapshot for error (to track it)
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: 0,
responseTime: 0,
changed: false,
errorMessage,
});
// Create snapshot for error (to track it)
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
contentHash: '',
httpStatus: 0,
responseTime: 0,
changed: false,
errorMessage,
});
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'error',
title: `Error monitoring: ${monitor.name}`,
summary: errorMessage,
channels: ['email'],
});
console.log(`[Alert] Error alert sent to ${user.email} for monitor ${monitor.name}`);
} catch (error) {
console.error('[Alert] Failed to send error alert:', error);
}
await dispatchAlert(user, monitor, snapshot, 'error', {
title: `Error monitoring: ${monitor.name}`,
summary: errorMessage,
htmlContent,
viewUrl: monitorUrl,
color: 'danger',
errorMessage,
});
}
export async function sendKeywordAlert(
monitor: Monitor,
user: User,
match: KeywordMatch
): Promise<void> {
const monitorUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}`;
let message = '';
switch (match.type) {
case 'appeared':
message = `The keyword "${match.keyword}" appeared on the page`;
break;
case 'disappeared':
message = `The keyword "${match.keyword}" disappeared from the page`;
break;
case 'count_changed':
message = `The keyword "${match.keyword}" count changed from ${match.previousCount} to ${match.currentCount}`;
break;
}
const htmlContent = `
<h2>Keyword Alert</h2>
<p>A keyword you're watching changed on: <strong>${monitor.name}</strong></p>
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #17a2b8;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Alert:</strong> ${message}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
</p>
`;
// Get latest snapshot
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
await dispatchAlert(user, monitor, snapshot, 'keyword', {
title: `Keyword alert: ${monitor.name}`,
summary: message,
htmlContent,
viewUrl: monitorUrl,
color: match.type === 'appeared' ? 'good' : 'warning',
keywordMatch: match,
});
}
// ============================================
// Email-only Functions (Auth flows)
// ============================================
export async function sendPasswordResetEmail(
email: string,
resetUrl: string
): Promise<void> {
try {
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
let message = '';
switch (match.type) {
case 'appeared':
message = `The keyword "${match.keyword}" appeared on the page`;
break;
case 'disappeared':
message = `The keyword "${match.keyword}" disappeared from the page`;
break;
case 'count_changed':
message = `The keyword "${match.keyword}" count changed from ${match.previousCount} to ${match.currentCount}`;
break;
}
const mailOptions = {
from: EMAIL_FROM,
to: user.email,
subject: `Keyword alert: ${monitor.name}`,
from: APP_CONFIG.emailFrom,
to: email,
subject: 'Password Reset Request',
html: `
<h2>Keyword Alert</h2>
<p>A keyword you're watching changed on: <strong>${monitor.name}</strong></p>
<h2>Password Reset Request</h2>
<p>You requested to reset your password for your Website Monitor account.</p>
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #17a2b8;">
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
<p><strong>Alert:</strong> ${message}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>
<p>Click the button below to reset your password. This link will expire in 1 hour.</p>
<p>
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
View Monitor
<a href="${resetUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
Reset Password
</a>
</p>
<p style="color: #666; font-size: 12px; margin-top: 30px;">
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
${resetUrl}
</p>
<p style="color: #666; margin-top: 30px;">
If you didn't request a password reset, you can safely ignore this email.
Your password will not be changed.
</p>
<p style="color: #999; font-size: 12px; margin-top: 30px;">
This is an automated email. Please do not reply.
</p>
`,
};
await transporter.sendMail(mailOptions);
// Get latest snapshot
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
if (snapshot) {
// Create alert record
await db.alerts.create({
monitorId: monitor.id,
snapshotId: snapshot.id,
userId: user.id,
type: 'keyword',
title: `Keyword alert: ${monitor.name}`,
summary: message,
channels: ['email'],
});
}
console.log(`[Alert] Keyword alert sent to ${user.email} for monitor ${monitor.name}`);
console.log(`[Alert] Password reset email sent to ${email}`);
} catch (error) {
console.error('[Alert] Failed to send keyword alert:', error);
console.error('[Alert] Failed to send password reset email:', error);
throw error;
}
}
export async function sendEmailVerification(
email: string,
verificationUrl: string
): Promise<void> {
try {
const mailOptions = {
from: APP_CONFIG.emailFrom,
to: email,
subject: 'Verify Your Email - Website Monitor',
html: `
<h2>Welcome to Website Monitor!</h2>
<p>Thank you for signing up. Please verify your email address to activate your account.</p>
<p>Click the button below to verify your email:</p>
<p>
<a href="${verificationUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
Verify Email Address
</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
${verificationUrl}
</p>
<p style="color: #666; margin-top: 30px;">
This verification link will expire in 24 hours.
</p>
<p style="color: #999; font-size: 12px; margin-top: 30px;">
If you didn't create an account, you can safely ignore this email.
</p>
`,
};
await transporter.sendMail(mailOptions);
console.log(`[Alert] Verification email sent to ${email}`);
} catch (error) {
console.error('[Alert] Failed to send verification email:', error);
throw error;
}
}

View File

@ -1,4 +1,4 @@
import { diffLines, diffWords, Change } from 'diff';
import { diffLines, Change } from 'diff';
import * as cheerio from 'cheerio';
import { IgnoreRule, KeywordRule } from '../types';
@ -69,13 +69,31 @@ export function applyCommonNoiseFilters(html: string): string {
let processedHtml = $.html();
// Remove common timestamp patterns
// Enhanced timestamp patterns to catch more formats
const timestampPatterns = [
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi, // ISO timestamps
// ISO timestamps
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi,
// Date formats
/\d{1,2}\/\d{1,2}\/\d{4}/gi, // MM/DD/YYYY
/\d{1,2}-\d{1,2}-\d{4}/gi, // MM-DD-YYYY
/Last updated:?\s*\d+/gi,
/Updated:?\s*\d+/gi,
/\d{4}\/\d{1,2}\/\d{1,2}/gi, // YYYY/MM/DD
/\d{4}-\d{1,2}-\d{1,2}/gi, // YYYY-MM-DD
// Time formats
/\d{1,2}:\d{2}:\d{2}/gi, // HH:MM:SS
/\d{1,2}:\d{2}\s?(AM|PM|am|pm)/gi, // HH:MM AM/PM
// Common date patterns with month names
/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/gi,
/\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}/gi,
// Timestamps with labels
/Last updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Modified:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Posted:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
/Published:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
// Unix timestamps (10 or 13 digits)
/\b\d{10,13}\b/g,
// Relative times
/\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago/gi,
];
timestampPatterns.forEach((pattern) => {

View File

@ -0,0 +1,282 @@
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import nodemailer from 'nodemailer';
import db from '../db';
// Redis connection (reuse from main scheduler)
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
// Digest queue
export const digestQueue = new Queue('change-digests', {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: 10,
removeOnFail: 10,
},
});
// Email transporter (same config as alerter)
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@websitemonitor.com';
interface DigestChange {
monitorId: string;
monitorName: string;
monitorUrl: string;
changePercentage: number;
changedAt: Date;
importanceScore: number;
}
interface DigestUser {
id: string;
email: string;
digestInterval: 'daily' | 'weekly' | 'none';
lastDigestAt: Date | null;
}
/**
* Get users who need a digest email
*/
async function getUsersForDigest(interval: 'daily' | 'weekly'): Promise<DigestUser[]> {
const cutoffHours = interval === 'daily' ? 24 : 168; // 24h or 7 days
const result = await db.query(
`SELECT id, email,
COALESCE(notification_preferences->>'digestInterval', 'none') as "digestInterval",
last_digest_at as "lastDigestAt"
FROM users
WHERE COALESCE(notification_preferences->>'digestInterval', 'none') = $1
AND (last_digest_at IS NULL OR last_digest_at < NOW() - INTERVAL '${cutoffHours} hours')`,
[interval]
);
return result.rows;
}
/**
* Get changes for a user since their last digest
*/
async function getChangesForUser(userId: string, since: Date): Promise<DigestChange[]> {
const result = await db.query(
`SELECT
m.id as "monitorId",
m.name as "monitorName",
m.url as "monitorUrl",
s.change_percentage as "changePercentage",
s.checked_at as "changedAt",
COALESCE(s.importance_score, 50) as "importanceScore"
FROM monitors m
JOIN snapshots s ON s.monitor_id = m.id
WHERE m.user_id = $1
AND s.has_changes = true
AND s.checked_at > $2
ORDER BY s.importance_score DESC, s.checked_at DESC
LIMIT 50`,
[userId, since]
);
return result.rows;
}
/**
* Generate HTML for the digest email
*/
function generateDigestHtml(changes: DigestChange[], interval: string): string {
const periodText = interval === 'daily' ? 'today' : 'this week';
if (changes.length === 0) {
return `
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">📊 Your Change Digest</h2>
<p style="color: #666;">No changes detected ${periodText}. All quiet on your monitors!</p>
<p style="color: #999; font-size: 12px;">Visit <a href="${APP_URL}/monitors">your dashboard</a> to manage your monitors.</p>
</div>
`;
}
// Group by importance
const highImportance = changes.filter(c => c.importanceScore >= 70);
const mediumImportance = changes.filter(c => c.importanceScore >= 40 && c.importanceScore < 70);
const lowImportance = changes.filter(c => c.importanceScore < 40);
let html = `
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">📊 Your Change Digest</h2>
<p style="color: #666;">Here's what changed ${periodText}:</p>
<div style="background: #f5f5f0; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<strong style="color: #333;">${changes.length} changes</strong> detected across your monitors
</div>
`;
if (highImportance.length > 0) {
html += `
<h3 style="color: #e74c3c; margin-top: 20px;">🔴 High Priority (${highImportance.length})</h3>
${generateChangesList(highImportance)}
`;
}
if (mediumImportance.length > 0) {
html += `
<h3 style="color: #f39c12; margin-top: 20px;">🟡 Medium Priority (${mediumImportance.length})</h3>
${generateChangesList(mediumImportance)}
`;
}
if (lowImportance.length > 0) {
html += `
<h3 style="color: #27ae60; margin-top: 20px;">🟢 Low Priority (${lowImportance.length})</h3>
${generateChangesList(lowImportance)}
`;
}
html += `
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">
<a href="${APP_URL}/settings">Manage digest settings</a> |
<a href="${APP_URL}/monitors">View all monitors</a>
</p>
</div>
`;
return html;
}
function generateChangesList(changes: DigestChange[]): string {
return `
<table style="width: 100%; border-collapse: collapse;">
${changes.map(c => `
<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px 0;">
<strong style="color: #333;">${c.monitorName}</strong>
<br>
<span style="color: #999; font-size: 12px;">${c.monitorUrl}</span>
</td>
<td style="padding: 10px 0; text-align: right;">
<span style="background: ${c.changePercentage > 50 ? '#e74c3c' : c.changePercentage > 10 ? '#f39c12' : '#27ae60'}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
${c.changePercentage.toFixed(1)}% changed
</span>
</td>
</tr>
`).join('')}
</table>
`;
}
/**
* Send digest email to user
*/
async function sendDigestEmail(user: DigestUser, changes: DigestChange[]): Promise<void> {
const subject = changes.length > 0
? `📊 ${changes.length} change${changes.length > 1 ? 's' : ''} detected on your monitors`
: '📊 Your monitor digest - All quiet!';
const html = generateDigestHtml(changes, user.digestInterval);
await transporter.sendMail({
from: EMAIL_FROM,
to: user.email,
subject,
html,
});
// Update last digest timestamp
await db.query(
'UPDATE users SET last_digest_at = NOW() WHERE id = $1',
[user.id]
);
console.log(`[Digest] Sent ${user.digestInterval} digest to ${user.email} with ${changes.length} changes`);
}
/**
* Process all pending digests
*/
export async function processDigests(interval: 'daily' | 'weekly'): Promise<void> {
console.log(`[Digest] Processing ${interval} digests...`);
const users = await getUsersForDigest(interval);
console.log(`[Digest] Found ${users.length} users for ${interval} digest`);
for (const user of users) {
try {
const since = user.lastDigestAt || new Date(Date.now() - (interval === 'daily' ? 24 : 168) * 60 * 60 * 1000);
const changes = await getChangesForUser(user.id, since);
await sendDigestEmail(user, changes);
} catch (error) {
console.error(`[Digest] Error sending digest to ${user.email}:`, error);
}
}
}
/**
* Schedule digest jobs (call on server start)
*/
export async function scheduleDigestJobs(): Promise<void> {
// Daily digest at 9 AM
await digestQueue.add(
'daily-digest',
{ interval: 'daily' },
{
jobId: 'daily-digest',
repeat: {
pattern: '0 9 * * *', // Every day at 9 AM
},
}
);
// Weekly digest on Mondays at 9 AM
await digestQueue.add(
'weekly-digest',
{ interval: 'weekly' },
{
jobId: 'weekly-digest',
repeat: {
pattern: '0 9 * * 1', // Every Monday at 9 AM
},
}
);
console.log('[Digest] Scheduled daily and weekly digest jobs');
}
/**
* Start digest worker
*/
export function startDigestWorker(): Worker {
const worker = new Worker(
'change-digests',
async (job) => {
const { interval } = job.data;
await processDigests(interval);
},
{
connection: redisConnection,
concurrency: 1,
}
);
worker.on('completed', (job) => {
console.log(`[Digest] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[Digest] Job ${job?.id} failed:`, err.message);
});
console.log('[Digest] Worker started');
return worker;
}

View File

@ -24,6 +24,7 @@ export async function fetchPage(
const response: AxiosResponse = await axios.get(url, {
timeout: 30000,
maxRedirects: 5,
responseType: 'text', // Force text response to avoid auto-parsing JSON
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
@ -38,7 +39,7 @@ export async function fetchPage(
});
const responseTime = Date.now() - startTime;
let html = response.data;
let html = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
// If element selector is provided, extract only that element
if (elementSelector) {

View File

@ -0,0 +1,190 @@
import db from '../db';
interface ImportanceFactors {
changePercentage: number; // 0-100
keywordMatches: number; // Anzahl wichtiger Keywords
isMainContent: boolean; // Haupt- vs. Sidebar-Content
isRecurringPattern: boolean; // Wiederkehrendes Muster (z.B. täglich)
contentLength: number; // Länge des geänderten Contents
}
/**
* Calculate importance score for a change (0-100)
* Higher scores indicate more significant changes
*/
export function calculateImportanceScore(factors: ImportanceFactors): number {
let score = 0;
// 1. Change Percentage (max 40 points)
// - Small changes (<5%) = low importance
// - Medium changes (5-20%) = medium importance
// - Large changes (>20%) = high importance
if (factors.changePercentage < 5) {
score += factors.changePercentage * 2; // 0-10 points
} else if (factors.changePercentage < 20) {
score += 10 + (factors.changePercentage - 5) * 1.5; // 10-32.5 points
} else {
score += Math.min(32.5 + (factors.changePercentage - 20) * 0.5, 40); // 32.5-40 points
}
// 2. Keyword matches (max 30 points)
// Each keyword match adds importance
score += Math.min(factors.keywordMatches * 10, 30);
// 3. Main content bonus (20 points)
// Changes in main content area are more important than sidebar/footer
if (factors.isMainContent) {
score += 20;
}
// 4. Content length consideration (max 10 points)
// Longer changes tend to be more significant
if (factors.contentLength > 500) {
score += 10;
} else if (factors.contentLength > 100) {
score += 5;
} else if (factors.contentLength > 50) {
score += 2;
}
// 5. Recurring pattern penalty (-15 points)
// If changes happen at the same time pattern, they're likely automated/less important
if (factors.isRecurringPattern) {
score -= 15;
}
// Clamp to 0-100
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* Check if monitor has a recurring change pattern
* (changes occurring at similar times/intervals)
*/
export async function detectRecurringPattern(monitorId: string): Promise<boolean> {
try {
// Get last 10 changes for this monitor
const result = await db.query(
`SELECT checked_at
FROM snapshots
WHERE monitor_id = $1 AND has_changes = true
ORDER BY checked_at DESC
LIMIT 10`,
[monitorId]
);
if (result.rows.length < 3) {
return false; // Not enough data to detect pattern
}
const timestamps = result.rows.map((r: any) => new Date(r.checked_at));
// Check for same-hour pattern (changes always at similar hour)
const hours = timestamps.map((t: Date) => t.getHours());
const hourCounts: Record<number, number> = {};
hours.forEach((h: number) => {
hourCounts[h] = (hourCounts[h] || 0) + 1;
});
// If more than 60% of changes happen at the same hour, it's a pattern
const maxHourCount = Math.max(...Object.values(hourCounts));
if (maxHourCount / timestamps.length > 0.6) {
return true;
}
// Check for regular interval pattern
if (timestamps.length >= 3) {
const intervals: number[] = [];
for (let i = 0; i < timestamps.length - 1; i++) {
intervals.push(timestamps[i].getTime() - timestamps[i + 1].getTime());
}
// Check if intervals are consistent (within 10% variance)
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const variance = intervals.map(i => Math.abs(i - avgInterval) / avgInterval);
const avgVariance = variance.reduce((a, b) => a + b, 0) / variance.length;
if (avgVariance < 0.1) {
return true; // Changes happen at regular intervals
}
}
return false;
} catch (error) {
console.error('[Importance] Error detecting pattern:', error);
return false;
}
}
/**
* Simple heuristic to detect if change is in main content area
* Based on common HTML patterns
*/
export function isMainContentChange(htmlDiff: string): boolean {
const mainContentPatterns = [
'<main', '</main>',
'<article', '</article>',
'class="content"', 'class="main"',
'id="content"', 'id="main"',
'<h1', '<h2', '<h3',
'<p>', '</p>'
];
const sidebarPatterns = [
'<aside', '</aside>',
'<nav', '</nav>',
'<footer', '</footer>',
'<header', '</header>',
'class="sidebar"', 'class="nav"',
'class="footer"', 'class="header"'
];
const lowerDiff = htmlDiff.toLowerCase();
let mainContentScore = 0;
let sidebarScore = 0;
mainContentPatterns.forEach(pattern => {
if (lowerDiff.includes(pattern.toLowerCase())) {
mainContentScore++;
}
});
sidebarPatterns.forEach(pattern => {
if (lowerDiff.includes(pattern.toLowerCase())) {
sidebarScore++;
}
});
return mainContentScore >= sidebarScore;
}
/**
* Get importance level label from score
*/
export function getImportanceLevel(score: number): 'high' | 'medium' | 'low' {
if (score >= 70) return 'high';
if (score >= 40) return 'medium';
return 'low';
}
/**
* Calculate importance for a specific change
*/
export async function calculateChangeImportance(
monitorId: string,
changePercentage: number,
keywordMatches: number,
diffContent: string
): Promise<number> {
const isRecurring = await detectRecurringPattern(monitorId);
const isMainContent = isMainContentChange(diffContent);
return calculateImportanceScore({
changePercentage,
keywordMatches,
isMainContent,
isRecurringPattern: isRecurring,
contentLength: diffContent.length
});
}

View File

@ -1,5 +1,5 @@
import db from '../db';
import { Monitor } from '../types';
import { Monitor, Snapshot } from '../types';
import { fetchPage } from './fetcher';
import {
applyIgnoreRules,
@ -7,9 +7,16 @@ import {
compareDiffs,
checkKeywords,
} from './differ';
import { calculateChangeImportance } from './importance';
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
import { generateSimpleSummary, generateAISummary } from './summarizer';
export async function checkMonitor(monitorId: string): Promise<void> {
export interface CheckResult {
snapshot: Snapshot;
alertSent: boolean;
}
export async function checkMonitor(monitorId: string): Promise<CheckResult | void> {
console.log(`[Monitor] Checking monitor ${monitorId}`);
try {
@ -20,8 +27,8 @@ export async function checkMonitor(monitorId: string): Promise<void> {
return;
}
if (monitor.status !== 'active') {
console.log(`[Monitor] Monitor ${monitorId} is not active, skipping`);
if (monitor.status !== 'active' && monitor.status !== 'error') {
console.log(`[Monitor] Monitor ${monitorId} is not active or error, skipping (status: ${monitor.status})`);
return;
}
@ -41,12 +48,18 @@ export async function checkMonitor(monitorId: string): Promise<void> {
}
}
// Check for HTTP error status
if (!fetchResult.error && fetchResult.status >= 400) {
fetchResult.error = `HTTP ${fetchResult.status}`;
}
// If still failing after retries
if (fetchResult.error) {
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
// Create error snapshot
await db.snapshots.create({
const failedSnapshot = await db.snapshots.create({
monitorId: monitor.id,
htmlContent: '',
textContent: '',
@ -67,10 +80,12 @@ export async function checkMonitor(monitorId: string): Promise<void> {
}
}
return;
return { snapshot: failedSnapshot, alertSent: false };
}
// Apply noise filters
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules));
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
@ -79,6 +94,7 @@ export async function checkMonitor(monitorId: string): Promise<void> {
let changed = false;
let changePercentage = 0;
let diffResult: ReturnType<typeof compareDiffs> | undefined;
if (previousSnapshot) {
// Apply same filters to previous content for fair comparison
@ -86,12 +102,12 @@ export async function checkMonitor(monitorId: string): Promise<void> {
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
// Compare
const diffResult = compareDiffs(previousHtml, processedHtml);
diffResult = compareDiffs(previousHtml, processedHtml);
changed = diffResult.changed;
changePercentage = diffResult.changePercentage;
console.log(
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}`
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
);
// Check keywords
@ -119,6 +135,33 @@ export async function checkMonitor(monitorId: string): Promise<void> {
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
}
// Generate human-readable summary (Hybrid approach)
let summary: string | undefined;
if (changed && previousSnapshot && diffResult) {
// Hybrid logic: AI for changes (≥5%), simple for very small changes
if (changePercentage >= 5) {
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
try {
summary = await generateAISummary(diffResult.diff, changePercentage);
} catch (error) {
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
} else {
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
summary = generateSimpleSummary(
diffResult.diff,
previousSnapshot.htmlContent,
fetchResult.html
);
}
}
// Create snapshot
const snapshot = await db.snapshots.create({
monitorId: monitor.id,
@ -129,6 +172,8 @@ export async function checkMonitor(monitorId: string): Promise<void> {
responseTime: fetchResult.responseTime,
changed,
changePercentage: changed ? changePercentage : undefined,
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
summary,
});
// Update monitor
@ -136,23 +181,36 @@ export async function checkMonitor(monitorId: string): Promise<void> {
// Send alert if changed and not first check
if (changed && previousSnapshot) {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
try {
const user = await db.users.findById(monitor.userId);
if (user) {
await sendChangeAlert(monitor, user, snapshot, changePercentage);
}
} catch (alertError) {
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
// Continue execution - do not fail the check
}
}
// Clean up old snapshots (keep last 50)
await db.snapshots.deleteOldSnapshots(monitor.id, 50);
// Clean up old snapshots based on user plan retention period
try {
const retentionUser = await db.users.findById(monitor.userId);
if (retentionUser) {
const { getRetentionDays } = await import('../config');
const retentionDays = getRetentionDays(retentionUser.plan);
await db.snapshots.deleteOldSnapshotsByAge(monitor.id, retentionDays);
}
} catch (cleanupError) {
console.error(`[Monitor] Failed to cleanup snapshots for ${monitor.id}:`, cleanupError);
}
console.log(`[Monitor] Check completed for ${monitor.name}`);
return { snapshot, alertSent: changed && !!previousSnapshot };
} catch (error) {
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
await db.monitors.incrementErrors(monitorId);
}
}
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
// This will be implemented when we add the job queue
console.log(`[Monitor] Scheduling monitor ${monitor.id} with frequency ${monitor.frequency}m`);
}
// Re-export scheduler functions for backward compatibility
export { scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from './scheduler';

View File

@ -0,0 +1,197 @@
import { Queue, Worker, QueueEvents } from 'bullmq';
import Redis from 'ioredis';
import { checkMonitor } from './monitor';
import { Monitor } from '../db';
// Redis connection
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
maxRetriesPerRequest: null,
});
// Monitor check queue
export const monitorQueue = new Queue('monitor-checks', {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
attempts: 3, // Retry failed jobs 3 times
backoff: {
type: 'exponential',
delay: 5000, // Start with 5 second delay
},
},
});
// Queue events for monitoring
const queueEvents = new QueueEvents('monitor-checks', { connection: redisConnection });
queueEvents.on('completed', ({ jobId }) => {
console.log(`[Scheduler] Job ${jobId} completed`);
});
queueEvents.on('failed', ({ jobId, failedReason }) => {
console.error(`[Scheduler] Job ${jobId} failed:`, failedReason);
});
/**
* Schedule a monitor for recurring checks
*/
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
if (monitor.status !== 'active') {
console.log(`[Scheduler] Skipping inactive monitor ${monitor.id}`);
return;
}
const jobId = `monitor-${monitor.id}`;
try {
// Remove existing job if it exists
await unscheduleMonitor(monitor.id);
// Add new recurring job
await monitorQueue.add(
'check-monitor',
{
monitorId: monitor.id,
url: monitor.url,
name: monitor.name,
},
{
jobId,
repeat: {
every: monitor.frequency * 60 * 1000, // Convert minutes to milliseconds
},
}
);
console.log(
`[Scheduler] Monitor ${monitor.id} scheduled (frequency: ${monitor.frequency} min)`
);
} catch (error) {
console.error(`[Scheduler] Failed to schedule monitor ${monitor.id}:`, error);
throw error;
}
}
/**
* Remove a monitor from the schedule
*/
export async function unscheduleMonitor(monitorId: string): Promise<void> {
const jobId = `monitor-${monitorId}`;
try {
// Get all repeatable jobs
const repeatableJobs = await monitorQueue.getRepeatableJobs();
// Find and remove the job for this monitor
const job = repeatableJobs.find((j) => j.id === jobId);
if (job && job.key) {
await monitorQueue.removeRepeatableByKey(job.key);
console.log(`[Scheduler] Monitor ${monitorId} unscheduled`);
}
// Also remove any pending jobs with this ID
const jobs = await monitorQueue.getJobs(['waiting', 'delayed']);
for (const j of jobs) {
if (j.id === jobId) {
await j.remove();
}
}
} catch (error) {
console.error(`[Scheduler] Failed to unschedule monitor ${monitorId}:`, error);
// Don't throw - unscheduling is best-effort
}
}
/**
* Reschedule a monitor (useful when frequency changes)
*/
export async function rescheduleMonitor(monitor: Monitor): Promise<void> {
console.log(`[Scheduler] Rescheduling monitor ${monitor.id}`);
await unscheduleMonitor(monitor.id);
await scheduleMonitor(monitor);
}
/**
* Initialize the worker to process monitor checks
*/
export function startWorker(): Worker {
const worker = new Worker(
'monitor-checks',
async (job) => {
const { monitorId } = job.data;
console.log(`[Worker] Processing check for monitor ${monitorId}`);
try {
await checkMonitor(monitorId);
console.log(`[Worker] Successfully checked monitor ${monitorId}`);
} catch (error) {
console.error(`[Worker] Error checking monitor ${monitorId}:`, error);
throw error; // Re-throw to mark job as failed
}
},
{
connection: redisConnection,
concurrency: 5, // Process up to 5 monitors concurrently
}
);
worker.on('completed', (job) => {
console.log(`[Worker] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[Worker] Job ${job?.id} failed:`, err.message);
});
worker.on('error', (err) => {
console.error('[Worker] Worker error:', err);
});
console.log('[Worker] Monitor check worker started');
return worker;
}
/**
* Gracefully shutdown the scheduler
*/
export async function shutdownScheduler(): Promise<void> {
console.log('[Scheduler] Shutting down...');
try {
await monitorQueue.close();
await queueEvents.close();
await redisConnection.quit();
console.log('[Scheduler] Shutdown complete');
} catch (error) {
console.error('[Scheduler] Error during shutdown:', error);
}
}
/**
* Get scheduler stats for monitoring
*/
export async function getSchedulerStats() {
try {
const [waiting, active, completed, failed, delayed, repeatableJobs] = await Promise.all([
monitorQueue.getWaitingCount(),
monitorQueue.getActiveCount(),
monitorQueue.getCompletedCount(),
monitorQueue.getFailedCount(),
monitorQueue.getDelayedCount(),
monitorQueue.getRepeatableJobs(),
]);
return {
waiting,
active,
completed,
failed,
delayed,
scheduled: repeatableJobs.length,
};
} catch (error) {
console.error('[Scheduler] Error getting stats:', error);
return null;
}
}

View File

@ -0,0 +1,289 @@
import { Change } from 'diff';
import * as cheerio from 'cheerio';
import OpenAI from 'openai';
/**
* Generate a simple human-readable summary of changes
* Uses HTML parsing to count added/removed elements
*
* Example output: "3 text blocks changed, 2 new links added, 1 image removed"
*/
export function generateSimpleSummary(
diff: Change[],
htmlOld: string,
htmlNew: string
): string {
const parts: string[] = [];
// Extract text previews from diff
const textPreviews = extractTextPreviews(diff);
// Count changed text blocks from diff
const changedTextBlocks = countChangedTextNodes(diff);
if (changedTextBlocks > 0) {
parts.push(`${changedTextBlocks} text block${changedTextBlocks > 1 ? 's' : ''} changed`);
}
// Parse HTML to count structural changes
const addedLinks = countAddedElements(htmlOld, htmlNew, 'a');
const removedLinks = countRemovedElements(htmlOld, htmlNew, 'a');
const addedImages = countAddedElements(htmlOld, htmlNew, 'img');
const removedImages = countRemovedElements(htmlOld, htmlNew, 'img');
const addedTables = countAddedElements(htmlOld, htmlNew, 'table');
const removedTables = countRemovedElements(htmlOld, htmlNew, 'table');
const addedLists = countAddedElements(htmlOld, htmlNew, 'ul') + countAddedElements(htmlOld, htmlNew, 'ol');
const removedLists = countRemovedElements(htmlOld, htmlNew, 'ul') + countRemovedElements(htmlOld, htmlNew, 'ol');
// Add links
if (addedLinks > 0) {
parts.push(`${addedLinks} new link${addedLinks > 1 ? 's' : ''} added`);
}
if (removedLinks > 0) {
parts.push(`${removedLinks} link${removedLinks > 1 ? 's' : ''} removed`);
}
// Add images
if (addedImages > 0) {
parts.push(`${addedImages} new image${addedImages > 1 ? 's' : ''}`);
}
if (removedImages > 0) {
parts.push(`${removedImages} image${removedImages > 1 ? 's' : ''} removed`);
}
// Add tables
if (addedTables > 0) {
parts.push(`${addedTables} new table${addedTables > 1 ? 's' : ''}`);
}
if (removedTables > 0) {
parts.push(`${removedTables} table${removedTables > 1 ? 's' : ''} removed`);
}
// Add lists
if (addedLists > 0) {
parts.push(`${addedLists} new list${addedLists > 1 ? 's' : ''}`);
}
if (removedLists > 0) {
parts.push(`${removedLists} list${removedLists > 1 ? 's' : ''} removed`);
}
// If no specific changes found, return generic message
if (parts.length === 0 && textPreviews.length === 0) {
return 'Content updated';
}
// Build summary with text previews
let summary = parts.join(', ');
// Add text preview if available
if (textPreviews.length > 0) {
const previewText = textPreviews.slice(0, 2).join(' → ');
if (summary) {
summary += `. Changed: "${previewText}"`;
} else {
summary = `Text changed: "${previewText}"`;
}
}
return summary.charAt(0).toUpperCase() + summary.slice(1);
}
/**
* Extract short text previews from diff (focus on visible text, ignore code)
*/
function extractTextPreviews(diff: Change[]): string[] {
const previews: string[] = [];
const maxPreviewLength = 50;
for (const part of diff) {
if (part.added || part.removed) {
// Skip if it looks like CSS, JavaScript, or technical code
if (looksLikeCode(part.value)) {
continue;
}
// Extract visible text (strip HTML tags)
const text = extractVisibleText(part.value);
if (text.length > 5) { // Only include meaningful text
const truncated = text.length > maxPreviewLength
? text.substring(0, maxPreviewLength) + '...'
: text;
const prefix = part.added ? '+' : '-';
previews.push(`${prefix}${truncated}`);
}
}
}
return previews.slice(0, 4); // Limit to 4 previews
}
/**
* Check if text looks like code (CSS, JS, etc.)
*/
function looksLikeCode(text: string): boolean {
const codePatterns = [
/^\s*[\.\#@]\w+\s*\{/m, // CSS selectors (.class {, #id {, @media {)
/{\s*[a-z-]+\s*:\s*[^}]+}/i, // CSS properties ({ color: red; })
/function\s*\(/, // JavaScript function
/\bconst\s+\w+\s*=/, // JS const declaration
/\bvar\s+\w+\s*=/, // JS var declaration
/\blet\s+\w+\s*=/, // JS let declaration
/^\s*<script/im, // Script tags
/^\s*<style/im, // Style tags
/[a-z-]+\s*:\s*[#\d]+[a-z]+\s*;/i, // CSS property: value;
];
return codePatterns.some(pattern => pattern.test(text));
}
/**
* Extract visible text from HTML, focusing on meaningful content
*/
function extractVisibleText(html: string): string {
return html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles
.replace(/<[^>]*>/g, ' ') // Remove HTML tags
.replace(/&nbsp;/g, ' ') // Replace &nbsp;
.replace(/&[a-z]+;/gi, ' ') // Remove other HTML entities
.replace(/\{[^}]*\}/g, '') // Remove CSS blocks { }
.replace(/\s+/g, ' ') // Normalize whitespace
.trim();
}
/**
* Count changed text nodes from diff
*/
function countChangedTextNodes(diff: Change[]): number {
let count = 0;
diff.forEach((part) => {
if (part.added || part.removed) {
// Count non-empty lines as text blocks
const lines = part.value.split('\n').filter(line => line.trim().length > 0);
count += lines.length;
}
});
// Divide by 2 because additions and removals are counted separately
// but represent the same change
return Math.ceil(count / 2);
}
/**
* Count elements added between old and new HTML
*/
function countAddedElements(htmlOld: string, htmlNew: string, tag: string): number {
try {
const $old = cheerio.load(htmlOld);
const $new = cheerio.load(htmlNew);
const oldCount = $old(tag).length;
const newCount = $new(tag).length;
return Math.max(0, newCount - oldCount);
} catch (error) {
console.error(`[Summarizer] Error counting added ${tag}:`, error);
return 0;
}
}
/**
* Count elements removed between old and new HTML
*/
function countRemovedElements(htmlOld: string, htmlNew: string, tag: string): number {
try {
const $old = cheerio.load(htmlOld);
const $new = cheerio.load(htmlNew);
const oldCount = $old(tag).length;
const newCount = $new(tag).length;
return Math.max(0, oldCount - newCount);
} catch (error) {
console.error(`[Summarizer] Error counting removed ${tag}:`, error);
return 0;
}
}
/**
* Generate AI-powered summary for large changes (10%)
* Uses OpenAI API (GPT-4o-mini for cost-efficiency)
*/
export async function generateAISummary(
diff: Change[],
changePercentage: number
): Promise<string> {
try {
// Check if API key is configured
if (!process.env.OPENAI_API_KEY) {
console.warn('[Summarizer] OPENAI_API_KEY not configured, falling back to simple summary');
throw new Error('OPENAI_API_KEY not configured');
}
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// Format diff for AI (reduce token count)
const formattedDiff = formatDiffForAI(diff);
const prompt = `Analyze this website change and create a concise summary for non-programmers.
Focus on IMPORTANT changes only. Medium detail level.
Change percentage: ${changePercentage.toFixed(2)}%
Diff:
${formattedDiff}
Format: "Section name: What changed. Details if important."
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer."
Keep it under 100 words. Be specific about what changed, not how.`;
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini', // Fastest, cheapest
max_tokens: 200,
messages: [{ role: 'user', content: prompt }],
temperature: 0.3, // Low temperature for consistent, factual summaries
});
// Extract text from response
const summary = completion.choices[0]?.message?.content || 'Content updated';
console.log('[Summarizer] AI summary generated:', summary);
return summary.trim();
} catch (error) {
console.error('[Summarizer] AI summary failed:', error);
throw error; // Re-throw to allow fallback to simple summary
}
}
/**
* Format diff for AI to reduce token count
* Extracts only added/removed content, limits to ~1000 characters
*/
function formatDiffForAI(diff: Change[]): string {
const lines: string[] = [];
let charCount = 0;
const maxChars = 1000;
for (const part of diff) {
if (charCount >= maxChars) break;
if (part.added) {
const prefix = '+ ';
const content = part.value.trim().substring(0, 200); // Limit each chunk
lines.push(prefix + content);
charCount += prefix.length + content.length;
} else if (part.removed) {
const prefix = '- ';
const content = part.value.trim().substring(0, 200);
lines.push(prefix + content);
charCount += prefix.length + content.length;
}
// Skip unchanged parts to save tokens
}
return lines.join('\n');
}

View File

@ -14,6 +14,13 @@ export interface User {
passwordHash: string;
plan: UserPlan;
stripeCustomerId?: string;
emailVerified?: boolean;
emailVerifiedAt?: Date;
emailEnabled?: boolean;
webhookUrl?: string;
webhookEnabled?: boolean;
slackWebhookUrl?: string;
slackEnabled?: boolean;
createdAt: Date;
lastLoginAt?: Date;
}
@ -58,6 +65,8 @@ export interface Snapshot {
responseTime: number;
changed: boolean;
changePercentage?: number;
importanceScore?: number;
summary?: string;
errorMessage?: string;
createdAt: Date;
}

View File

@ -23,7 +23,7 @@ export function generateToken(user: User): string {
plan: user.plan,
};
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN as jwt.SignOptions['expiresIn'] });
}
export function verifyToken(token: string): JWTPayload {
@ -62,3 +62,35 @@ export function validatePassword(password: string): {
errors,
};
}
export function generatePasswordResetToken(email: string): string {
return jwt.sign({ email, type: 'password-reset' }, JWT_SECRET, { expiresIn: '1h' });
}
export function verifyPasswordResetToken(token: string): { email: string } {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
if (decoded.type !== 'password-reset') {
throw new Error('Invalid token type');
}
return { email: decoded.email };
} catch (error) {
throw new Error('Invalid or expired reset token');
}
}
export function generateEmailVerificationToken(email: string): string {
return jwt.sign({ email, type: 'email-verification' }, JWT_SECRET, { expiresIn: '24h' });
}
export function verifyEmailVerificationToken(token: string): { email: string } {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
if (decoded.type !== 'email-verification') {
throw new Error('Invalid token type');
}
return { email: decoded.email };
} catch (error) {
throw new Error('Invalid or expired verification token');
}
}

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -0,0 +1 @@
/c/Users/timo/Documents/Websites/website-monitor/backend

View File

@ -4,16 +4,40 @@
This is a **Website Change Detection Monitor SaaS** application. The core value proposition is helping users track changes on web pages they care about, with intelligent noise filtering to ensure only meaningful changes trigger alerts.
**Tagline**: "I watch pages so you don't have to"
**Updated Tagline (2026-01-18)**: "Less noise. More signal. Proof included."
**Previous**: "I watch pages so you don't have to" (too generic, doesn't communicate value)
**Target Market**: SEO & Growth Teams (SMB → Mid-Market) who monitor competitor pages, SERP changes, and policy updates
---
## Key Differentiators
## Key Differentiators (Market-Validated)
1. **Smart Noise Filtering**: Unlike competitors, we automatically filter out cookie banners, timestamps, rotating ads, and other irrelevant changes
2. **Keyword-Based Alerts**: Users can be notified when specific words/phrases appear or disappear (e.g., "sold out", "hiring", "$99")
3. **Simple but Powerful**: Easy enough for non-technical users, powerful enough for professionals
4. **SEO-Optimized Market**: Tons of long-tail keywords (e.g., "monitor job postings", "track competitor prices")
1. **Superior Noise Filtering** 🔥
- Automatically filter cookie banners, timestamps, rotating ads, session IDs
- Custom ignore rules (CSS selectors, regex, text patterns)
- **Market Evidence**: Distill & Fluxguard emphasize this as core differentiator
2. **Keyword-Based Alerts** 🔥
- Alert when specific words appear/disappear (e.g., "sold out", "hiring", "$99")
- Threshold-based triggers, regex support
- **Market Evidence**: High-value feature across all competitors
3. **Workflow Integrations** 🔥 NEW PRIORITY
- Webhooks (MVP), Slack (V1), Teams/Discord (V2)
- Alerts in your existing tools, not just email
- **Market Evidence**: Shown prominently by Visualping, Wachete, ChangeDetection
4. **Proof & History** 🔥
- Compare versions, audit-proof snapshots, full history
- Messaging: "Prove changes" not just "see changes"
- **Market Evidence**: Sken & Fluxguard highlight "versions kept"
5. **Use-Case-Focused Marketing**
- Primary: SEO Monitoring, Competitor Tracking, Policy/Compliance
- Secondary: Stock/Restock, Job Postings
- **Market Evidence**: All competitors segment by use case
---
@ -609,12 +633,48 @@ A: 5 minutes for paid users, 1 hour for free tier.
- **Element selection** - Monitor specific parts
- **Change severity** - Classify importance
### Pricing Tiers
### Pricing Tiers (Under Review - See findings_market.md)
- **Free**: 5 monitors, 1hr frequency
- **Pro**: 50 monitors, 5min frequency, $19-29/mo
- **Business**: 200 monitors, 1min frequency, teams, $99-149/mo
- **Enterprise**: Unlimited, custom pricing
**Note:** Considering switch to "checks/month" model instead of "monitors + frequency" for fairer pricing
---
*Last updated: 2026-01-16*
## Competitive Positioning (Updated 2026-01-18)
### Market Landscape
We compete with established players (Visualping, Distill, Fluxguard) and budget options (Sken.io, ChangeDetection.io).
### vs. Visualping
- **Their Strength**: Enterprise trust ("85% Fortune 500"), broad features
- **Our Angle**: "Better noise control + fairer pricing without the enterprise bloat"
- **Messaging**: "Built for teams who need results, not demos"
### vs. Distill.io
- **Their Strength**: Conditions/filters, established user base
- **Our Angle**: "Team features built-in + modern UX not stuck in 2015"
- **Messaging**: "Collaboration-first, not an afterthought"
### vs. Fluxguard
- **Their Strength**: AI summaries, enterprise focus, sales-led
- **Our Angle**: "Self-serve pricing + instant setup no demo calls required"
- **Messaging**: "AI-powered intelligence without the enterprise tax"
### vs. ChangeDetection.io / Sken.io
- **Their Strength**: Low price ($3-9/mo), simple
- **Our Angle**: "Advanced features (keywords, integrations, teams) without complexity"
- **Messaging**: "Powerful, but still simple"
### How We Win
1. **Superior noise control** (multi-layer filtering)
2. **Workflow integrations** (Slack/Teams/Webhooks early)
3. **Use-case marketing** (SEO, Competitor, Policy segments)
4. **Modern UX** (not stuck in legacy design)
5. **Fair pricing** (considering checks/month model)
---
*Last updated: 2026-01-18*

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

801
findings.md Normal file
View File

@ -0,0 +1,801 @@
Website Monitor - Umfassende Analyse & Verbesserungsplan
📊 Projekt-Status Übersicht
Implementierungsstatus nach Bereich
┌─────────────────────┬─────────┬───────────┬───────────────────────────┐
│ Bereich │ Status │ Qualität │ Kritische Issues │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Landing Page │ ✅ 100% │ Exzellent │ Keine │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Authentication │ ✅ 100% │ Gut │ Password Reset fehlt │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Dashboard │ ✅ 100% │ Gut │ Keine │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Monitors Management │ ✅ 100% │ Exzellent │ Keyword UI fehlt │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Monitor History │ ✅ 100% │ Gut │ Keine │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Snapshot Details │ ✅ 100% │ Exzellent │ Keine │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Analytics │ ⚠️ 60% │ Basic │ Keine Trends/Zeitbereiche │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Incidents │ ⚠️ 60% │ Basic │ Kein Resolution Tracking │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Settings │ ❌ 20% │ Stub │ Komplett nicht funktional │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Backend Core │ ✅ 95% │ Exzellent │ Job Scheduling fehlt │
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
│ Change Detection │ ✅ 100% │ Exzellent │ Funktioniert! │
└─────────────────────┴─────────┴───────────┴───────────────────────────┘
---
🚨 KRITISCHER BLOCKER (Muss vor Launch behoben werden)
Problem: Keine automatische Überwachung
Dateien:
- backend/src/services/monitor.ts (Zeile 168-171) - scheduleMonitor() ist nur ein Stub
- backend/src/index.ts - Queue-Initialisierung fehlt
- backend/src/routes/monitors.ts - Ruft scheduleMonitor() auf, aber die tut nichts
Aktueller Stand:
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
// This will be implemented when we add the job queue
console.log(`[Monitor] Scheduling monitor ${monitor.id}...`);
}
Auswirkung:
- ❌ Monitors prüfen NICHT automatisch im Hintergrund
- ❌ Nutzer müssen manuell "Check Now" klicken
- ❌ Das komplette Wertversprechen ("I watch pages so you don't have to") funktioniert nicht
- ✅ Manuelle Checks über API funktionieren (aber das ist nicht das Produkt)
Was fehlt:
1. Bull Queue Worker implementieren
2. Redis-Verbindung initialisieren
3. Recurring Jobs für jeden Monitor erstellen
4. Job-Processor der checkMonitor() aufruft
5. Job-Cleanup bei Monitor-Löschung/Pause
6. Job-Aktualisierung bei Frequency-Änderung
---
✅ Was tatsächlich FUNKTIONIERT (Change Detection Analyse)
Der Change Detection Algorithmus ist exzellent implementiert:
1. Multi-Layer Noise Filtering:
- ✅ 20+ Timestamp-Regex-Patterns (ISO, Unix, relative Zeiten)
- ✅ Cookie Banner via CSS Selektoren (20+ Patterns)
- ✅ Script/Style Tag Entfernung
- ✅ Custom Ignore Rules (CSS, Regex, Text)
2. Diff-Vergleich:
- ✅ Verwendet diff Library mit diffLines()
- ✅ Berechnet Change Percentage korrekt
- ✅ Zählt Additions/Deletions
- ✅ Severity Classification (major > 50%, medium > 10%, minor)
3. Keyword Detection:
- ✅ 3 Rule Types: appears, disappears, count
- ✅ Case-sensitive Support
- ✅ Threshold-basierte Triggering
- ✅ Detaillierte Match-Info
4. Error Handling:
- ✅ 3-Retry Logic mit Backoff
- ✅ Consecutive Error Tracking
- ✅ Automatische Error Alerts (ab 2 Fehlern)
Fazit: Der Core-Algorithmus ist produktionsreif und arbeitet zuverlässig.
---
📝 Detaillierte Feature-Analyse
1. Landing Page (frontend/app/page.tsx)
Status: ✅ Vollständig implementiert
Vorhandene Features:
- Hero Section mit CTAs
- Feature-Highlights (3 Differenzierungsmerkmale)
- "How it works" Steps
- 3-Tier Pricing (Starter/Pro/Enterprise)
- FAQ Accordion
- Responsive Design
Kleinere Issues:
- Demo-Video Link ist Platzhalter
- Pricing Buttons führen nicht zur Checkout-Flow
- "10,000+ developers" ist Hardcoded
---
2. Authentication (frontend/app/login, frontend/app/register)
Status: ✅ Vollständig, ⚠️ Features fehlen
Vorhanden:
- Login/Register mit Validation
- JWT Token Management
- Auto-Redirect bei Authentication
- Error Handling
- Loading States
Fehlt:
- ❌ Password Reset/Recovery Flow
- ❌ Email Verification
- ❌ "Remember Me" Funktionalität
- ❌ 2FA Support
- ❌ Social Auth (Google, GitHub)
---
3. Dashboard (frontend/app/dashboard/page.tsx)
Status: ✅ Gut implementiert
Features:
- 4 Stat Cards (Total, Active, Errors, Recent Changes)
- Recent Monitors List (Top 5)
- Quick Action Buttons
- Status Indicators
Verbesserungspotenzial:
- Keine Pagination (nur 5 Monitors)
- Keine Charts/Visualisierungen
- Keine Echtzeit-Updates
- Keine historischen Trends
---
4. Monitors Management (frontend/app/monitors/page.tsx)
Status: ✅ Exzellent implementiert
Starke Features:
- Grid/List View Toggle
- Filter Tabs (All, Active, Error)
- Inline Create/Edit Form
- Frequency Presets (5min bis 24h)
- Ignore Content Presets (Timestamps, Cookies, etc.)
- Custom CSS Selector Support
- Check Now, Edit, Delete Actions
- Konfirmations-Dialoge
Fehlt:
- ❌ Keyword Rules UI (Backend unterstützt es, aber kein UI!)
- ❌ Visual Element Picker
- ❌ Bulk Actions (mehrere Monitors gleichzeitig)
- ❌ Tags/Gruppierung
- ❌ Keyboard Shortcuts
---
5. Monitor History & Snapshots
Status: ✅ Sehr gut implementiert
Features:
- Timeline mit allen Checks
- Change/No Change/Error Badges
- HTTP Status + Response Time
- Change Percentage
- Error Messages
- Diff Viewer mit Split-View (react-diff-viewer-continued)
Verbesserungspotenzial:
- Keine Pagination (lädt alle 50 Snapshots auf einmal)
- Kein Zeitbereich-Filter
- Kein Export (PDF, CSV)
- Keine Screenshot-Vergleiche
---
6. Analytics (frontend/app/analytics/page.tsx)
Status: ⚠️ 60% - Basic Stats nur
Vorhanden:
- Total Monitors, Uptime Rate, Error Rate
- Monitor Status Distribution (Donut Chart)
- Check Frequency Distribution (Bar Charts)
Kritisch fehlend:
- ❌ Zeitbereich-Auswahl (7d, 30d, 90d)
- ❌ Trend-Charts (Change Frequency über Zeit)
- ❌ Response Time Trends
- ❌ Historische Vergleiche
- ❌ Per-Monitor Analytics
- ❌ Export-Funktionalität
---
7. Incidents (frontend/app/incidents/page.tsx)
Status: ⚠️ 60% - Sehr basic
Vorhanden:
- Liste von Errors + Changes
- Type Badges
- View Details Links
Fehlt:
- ❌ Incident Grouping (gleicher Monitor, gleicher Tag)
- ❌ Resolution Tracking (Mark as resolved)
- ❌ Severity Levels
- ❌ Incident Timeline
- ❌ Filter nach Datum/Typ
- ❌ Alert Delivery Status
---
8. Settings (frontend/app/settings/page.tsx)
Status: ❌ 20% - Nur UI Mockup
Problem: Alle Buttons sind nicht-funktional, keine Backend-Integration!
Was fehlt:
- ❌ Change Password Flow
- ❌ Email Notification Preferences
- ❌ Slack Integration Setup
- ❌ Webhook Configuration
- ❌ Billing Management (Stripe Portal Link)
- ❌ Account Deletion mit Confirmation
- ❌ Plan Management
---
🏗️ Backend Architektur-Analys
Datenbankschema (backend/src/db/schema.sql)
Status: ✅ Gut durchdacht
Tabellen:
- users - Email, password_hash, plan, stripe_customer_id
- monitors - URL, frequency, rules (JSONB), status tracking
- snapshots - HTML, text, hash, diff results, HTTP info
- alerts - Type, title, channels (JSONB), delivery tracking
Indexes: Gut gesetzt für Performance
Snapshot Retention: Automatisches Cleanup (letzte 50 behalten)
---
API Endpoints (backend/src/routes/monitors.ts)
Status: ✅ RESTful und vollständig
Endpoints:
- GET /api/monitors - Liste aller Monitors
- POST /api/monitors - Neuer Monitor (mit Plan Limits Check)
- GET /api/monitors/:id - Einzelner Monitor
- PUT /api/monitors/:id - Update Monitor
- DELETE /api/monitors/:id - Löschen
- POST /api/monitors/:id/check - Manueller Check (synchron!)
- GET /api/monitors/:id/history - Snapshot History (max 100)
- GET /api/monitors/:id/history/:snapshotId - Einzelner Snapshot
Plan Limits Enforcement:
- FREE: 5 Monitors, 60min Frequency
- PRO: 50 Monitors, 5min Frequency
- BUSINESS: 200 Monitors, 1min Frequency
- ENTERPRISE: Unlimited, 1min Frequency
Issue: /check Endpoint ist synchron (wartet bis Check fertig) - könnte bei langsamen Seiten timeouten
---
Alert System (backend/src/services/alerter.ts)
Status: ✅ Funktioniert
3 Alert-Typen:
1. Change Alert - bei erkannten Änderungen
2. Error Alert - nach 2 konsekutiven Fehlern
3. Keyword Alert - bei Keyword-Match
Email-Versand:
- Nodemailer mit SMTP (SendGrid konfiguriert)
- Benötigt SMTP_USER und SMTP_PASS in .env
---
🎨 Design System & Components
UI Components (frontend/components/ui/)
Status: ✅ Grundlagen vorhanden, ⚠️ Fortgeschrittene fehlen
Vorhanden (7 Components):
- Button (variants, sizes, loading state)
- Input (mit label, error, hint)
- Card (Header, Title, Content, Footer)
- Badge (status variants)
- Select (Dropdown)
Fehlt:
- Modal/Dialog (für Confirmations, Forms)
- Dropdown Menu (für Action Menus)
- Tabs (für Settings Sections)
- Pagination
- Data Table mit Sorting
- Toggle/Switch
- Progress Bar
- Tooltip
Design System (frontend/app/globals.css)
Status: ✅ Exzellent - Premium Look
Highlights:
- Warme Farbpalette (Tan/Sand Primary: #C4B29C)
- Dark Mode Support
- Custom Animations (fadeIn, slideIn, pulseGlow)
- Glass Panel Effects
- Status Dots (animated)
- Scrollbar Styling
Qualität: Sehr professionell, hebt sich von Generic Material Design ab
---
🔒 Sicherheit & Authentication
JWT Implementation (backend/src/middleware/auth.ts)
Status: ✅ Sicher implementiert
Features:
- JWT mit 7 Tage Expiry
- Bcrypt Password Hashing
- Password Requirements (8+ Zeichen, Upper/Lower/Number)
- Auto-Redirect bei 401
⚠️ Kritisch:
- Default JWT_SECRET ist 'your-secret-key' (Zeile 5 in utils/auth.ts)
- MUSS in Production geändert werden!
---
💡 Verbesserungsvorschläge (Priorisiert)
PRIORITÄT 1: MVP Blocker (Muss vor Launch)
1. Bull Queue Job Scheduling implementieren ⚠️ KRITISCH
Dateien:
- backend/src/services/scheduler.ts (neu erstellen)
- backend/src/index.ts (Queue initialisieren)
- backend/src/routes/monitors.ts (scheduleMonitor() aufrufen)
Aufwand: 3-4 Stunden
Implementierung:
// scheduler.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import { checkMonitor } from './monitor';
const redis = new Redis(process.env.REDIS_URL);
const monitorQueue = new Queue('monitor-checks', { connection: redis });
export async function scheduleMonitor(monitor: Monitor) {
await monitorQueue.add(
'check',
{ monitorId: monitor.id },
{
repeat: { every: monitor.frequency * 60 * 1000 },
jobId: `monitor-${monitor.id}`,
removeOnComplete: 100,
removeOnFail: false
}
);
}
export async function unscheduleMonitor(monitorId: string) {
await monitorQueue.remove(`monitor-${monitorId}`);
}
// Worker
const worker = new Worker(
'monitor-checks',
async (job) => {
await checkMonitor(job.data.monitorId);
},
{ connection: redis }
);
---
2. Settings Page Backend implementieren
Dateien:
- backend/src/routes/settings.ts (neu)
- frontend/app/settings/page.tsx (API-Integration)
Fehlende Features:
- Change Password Endpoint
- Update Notification Preferences
- Webhook CRUD Endpoints
- Slack OAuth Integration
- Billing Portal Link (Stripe)
- Account Deletion Endpoint
Aufwand: 4-5 Stunden
---
3. Password Reset Flow
Dateien:
- frontend/app/forgot-password/page.tsx (neu)
- frontend/app/reset-password/[token]/page.tsx (neu)
- backend/src/routes/auth.ts (Endpoints hinzufügen)
Flow:
1. User gibt Email ein
2. Backend generiert Reset Token (JWT, 1h Expiry)
3. Email mit Reset-Link
4. User setzt neues Passwort
5. Token wird invalidiert
Aufwand: 2-3 Stunden
---
4. Email Verification
Dateien:
- frontend/app/verify-email/[token]/page.tsx (neu)
- backend/src/routes/auth.ts (Endpoints)
- backend/src/services/alerter.ts (Check vor Alert-Versand)
Wichtig: Verhindert Spam-Accounts und verbessert Email-Deliverability
Aufwand: 2 Stunden
---
PRIORITÄT 2: Kern-Features komplettieren
5. Keyword Alerts UI implementieren 🔥 WICHTIG
Dateien:
- frontend/app/monitors/page.tsx (Form erweitern)
Backend funktioniert bereits perfekt! Nur UI fehlt.
Was hinzufügen:
- Keyword Rules Section im Monitor Form
- Add/Remove Keyword Rules
- Optionen: keyword, type (appears/disappears/count), threshold, case-sensitive
- Preview der Keyword-Matches in Snapshot Details
- Keyword Alert Badges in History
Aufwand: 3-4 Stunden
---
6. Advanced Noise Filtering UI
Aktuell: Nur Presets (Timestamps, Cookies, etc.)
Erweiterungen:
- Visual Element Selector (Click-to-Ignore)
- Regex-basierte Filter mit Preview
- Custom Filter Templates speichern
- Sensitivity Slider (Schwellenwert)
Aufwand: 3-4 Stunden
---
7. Mobile Responsiveness
Issues:
- Sidebar klappt nicht ein auf Mobile
- Monitor Cards zu breit auf kleinen Screens
- Form Inputs stacken nicht richtig
Aufwand: 2 Stunden
---
8. Analytics Enhancements
Fehlende Features:
- Zeitbereich-Selector (7d, 30d, 90d, all time)
- Change Frequency Trend Chart
- Response Time Graph
- Error Rate Trends
- Export als CSV
Aufwand: 3-4 Stunden
---
9. Incidents Improvements
Erweiterungen:
- Incident Grouping (gleicher Monitor, gleicher Tag)
- Mark as Resolved/Acknowledged
- Severity Indicators
- Filter nach Type/Date
- Incident Details Modal
Aufwand: 3 Stunden
---
PRIORITÄT 3: Competitive Advantages
10. AI-Powered Change Importance Scoring 🚀
Das wäre ein KILLER-Feature!
Konzept: Nicht alle Changes sind gleich wichtig. Score 0-100 basierend auf:
- Change Percentage
- Important Keywords enthalten?
- Main Content vs. Sidebar?
- Recurring Pattern (immer gleiche Zeit)?
- Optional: GPT-4o-mini für semantische Analyse
User Benefit: Nur bei wirklich wichtigen Changes benachrichtigen
Aufwand: 8-10 Stunden
---
11. Visual Element Selector
Problem: Aktuell muss User CSS Selector kennen
Lösung:
- Page in iframe rendern
- Overlay mit Click-Handler
- Element highlighten beim Hover
- Auto-generiere optimalen CSS Selector
- Test-Button um zu prüfen ob Selector funktioniert
Libraries: optimal-select, element-inspector
Aufwand: 6-8 Stunden
---
12. Smart Diff Visualization
Aktuell: Basic Side-by-Side
Verbesserungen:
- Inline Diff mit Highlighting
- Collapsible unchanged sections
- Visual Diff (Screenshot Comparison)
- Synchronized Scrolling
- Search within Diff
- Export as PDF
Aufwand: 4-5 Stunden
---
13. Monitor Templates Marketplace 💡
Konzept: Pre-configured Monitors für populäre Sites
Beispiele:
- "Amazon Product Price Tracker"
- "Reddit Job Postings"
- "GitHub Releases Watcher"
- "Competitor Pricing Page"
User installiert Template in 1-Click, ersetzt nur URL
Aufwand: 10+ Stunden
---
14. Change Digest Mode
Problem: Notification Fatigue
Lösung: Batch Changes in tägliche/wöchentliche Digests
- Per-Monitor oder Account-wide Setting
- Smart Grouping
- Beautiful Email Template
- "Top Changes This Week" Ranking
Aufwand: 4 Stunden
---
PRIORITÄT 4: Performance & Scale
15. Optimize Diff Calculation
Aktuelle Performance: Funktioniert, aber könnte schneller sein
Optimierungen:
- Stream large HTML (nicht in Memory laden)
- xxHash statt SHA-256 (schneller)
- Diff nur visible Text (strip HTML vorher)
- Cache filtered HTML
- Incremental Diffing
Aufwand: 3-4 Stunden
---
16. Add Pagination
Wo fehlt:
- Monitor History (lädt alle 50 auf einmal)
- Monitors List
- Incidents List
Aufwand: 2 Stunden
---
17. Implement Caching
Strategie:
- Redis Cache für Monitor List (1min TTL)
- Latest Snapshot per Monitor (1min TTL)
- User Plan Limits (10min TTL)
Aufwand: 3 Stunden
---
PRIORITÄT 5: Zukunft & Integrations
18. Webhook Integration
Status: Settings UI existiert, Backend fehlt
Implementation:
- Store Webhook URL per User
- POST JSON auf Change
- Retry Logic (3 Versuche)
- Webhook Logs
- HMAC Signature für Security
Aufwand: 2 Stunden
---
19. Slack Integration
Implementation:
- Slack OAuth Flow
- Post to Channel on Change
- Rich Message Formatting mit Buttons
- Configure per-Monitor oder Global
Aufwand: 4 Stunden
---
20. Browser Extension
Features:
- Right-Click → "Monitor this page"
- Auto-fill Form
- Visual Element Picker
- Quick Status View in Popup
Aufwand: 20+ Stunden
---
🎯 Empfohlene Implementierungs-Reihenfolge
Woche 1: Critical Blockers beheben
1. Tag 1-2: Bull Queue Job Scheduling ⚠️
2. Tag 2: Password Reset Flow
3. Tag 3: Email Verification
4. Tag 4-5: Settings Page Backend
Deliverable: Voll funktionales MVP mit automatischem Monitoring
---
Woche 2: Core Features komplettieren
1. Tag 1-2: Keyword Alerts UI
2. Tag 2: Mobile Responsiveness
3. Tag 3-4: Fehlende UI Components (Modal, Dropdown, etc.)
4. Tag 4-5: Analytics & Incidents Enhancements
Deliverable: Feature-complete Product für Beta Users
---
Woche 3: Competitive Advantages
1. Tag 1-3: AI Change Importance Scoring
2. Tag 3-5: Visual Element Selector
Deliverable: Unique Features die Konkurrenz nicht hat
---
Woche 4: Polish & Performance
1. Tag 1-2: Advanced Noise Filtering UI
2. Tag 2-3: Smart Diff Visualization
3. Tag 4: Caching & Pagination
4. Tag 5: Database Optimization & Testing
Deliverable: Production-ready, scalable Product
---
📊 Impact Matrix
┌─────────────────────────┬────────────┬───────────┬─────────┬───────────┐
│ Feature │ User Value │ Tech Risk │ Aufwand │ Priorität │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Bull Queue Scheduling │ 🔥🔥🔥🔥🔥 │ Niedrig │ Mittel │ P1 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Settings Backend │ 🔥🔥🔥🔥 │ Niedrig │ Mittel │ P1 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Password Reset │ 🔥🔥🔥🔥 │ Niedrig │ Klein │ P1 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Email Verification │ 🔥🔥🔥🔥 │ Niedrig │ Klein │ P1 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Keyword Alerts UI │ 🔥🔥🔥🔥🔥 │ Niedrig │ Mittel │ P2 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ AI Importance Scoring │ 🔥🔥🔥🔥🔥 │ Mittel │ Groß │ P3 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Visual Element Selector │ 🔥🔥🔥🔥 │ Mittel │ Groß │ P3 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Mobile Responsive │ 🔥🔥🔥 │ Niedrig │ Klein │ P2 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Analytics Enhancements │ 🔥🔥🔥 │ Niedrig │ Mittel │ P2 │
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
│ Monitor Templates │ 🔥🔥🔥🔥 │ Niedrig │ Groß │ P3 │
└─────────────────────────┴────────────┴───────────┴─────────┴───────────┘
---
🔧 Kritische Dateien für Implementation
Für Bull Queue (P1 - CRITICAL):
- backend/src/services/scheduler.ts - Komplett neu erstellen
- backend/src/index.ts - Queue initialisieren beim Server Start
- backend/src/routes/monitors.ts - scheduleMonitor() nach create/update aufrufen
Für Settings (P1):
- backend/src/routes/settings.ts - Neu erstellen
- frontend/app/settings/page.tsx - API Integration hinzufügen
Für Keyword Alerts UI (P2):
- frontend/app/monitors/page.tsx - Form um Keyword Rules Section erweitern
- frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx - Keyword Matches anzeigen
Für Auth Erweiterungen (P1):
- frontend/app/forgot-password/page.tsx - Neu
- frontend/app/reset-password/[token]/page.tsx - Neu
- frontend/app/verify-email/[token]/page.tsx - Neu
- backend/src/routes/auth.ts - Endpoints hinzufügen
---
🎨 Kreative Differenzierungs-Ideen
1. Change Prediction
Historical Data nutzen um zu predicten wann Changes typischerweise auftreten. Check Frequency automatisch erhöhen um vorhergesagte Zeiten.
2. Natural Language Monitoring
"Alert me when this page mentions hiring OR job openings"
→ System übersetzt automatisch in Keyword Rules
3. Collaborative Change Annotations
Users können Notes auf Changes hinterlassen: "Expected change" oder "False alarm". Im Team teilen.
4. Change Feed (RSS-like)
Public/Authenticated Feed aller Changes. Power Users können via RSS Reader konsumieren.
5. Monitor Health Score
Track Reliability jedes Monitors (success rate, false positive rate). Auto-suggest Improvements.
---
✅ Zusammenfassung
Was EXZELLENT funktioniert:
- ✅ Change Detection Algorithmus (Multi-Layer Filtering, Diff, Keywords)
- ✅ Frontend Design & Core Features (Landing, Auth, Dashboard, Monitors)
- ✅ Database Schema & API Endpoints
- ✅ Security (JWT, Password Hashing, Authorization)
KRITISCHER Blocker:
- ❌ Keine automatische Überwachung - Bull Queue nicht implementiert
Fehlende Features für MVP:
- ❌ Settings Page Funktionalität
- ❌ Password Reset & Email Verification
- ❌ Keyword Alerts UI
- ❌ Mobile Responsiveness
Empfehlung:
Fokus auf Woche 1 Plan - Die 4 P1 Blocker beheben macht das Produkt voll funktional und launchbar. Dann iterativ weitere Features hinzufügen basierend auf User Feedback.
Das Produkt ist 85% fertig und hat eine exzellente technische Basis. Mit 1-2 Wochen fokussierter Arbeit kann es production-ready sein!

441
findings_market.md Normal file
View File

@ -0,0 +1,441 @@
# Competitive Intelligence - Website Change Detection Market Analysis
**Analysis Date:** 2026-01-18
**Source:** ChatGPT Market Crawl (7 competitors: Visualping, Distill, Hexowatch, Wachete, Sken.io, ChangeDetection.io, Fluxguard)
---
## Executive Summary
Der Website Change Detection Markt ist **mature und competitive**. Erfolg hängt ab von:
1. **Noise Control Quality** (wichtigster Differentiator)
2. **Workflow Integrations** (Slack/Teams/Webhooks sind Pflicht, nicht optional)
3. **Proof/History Features** ("Beweise Änderungen" > "Sieh Änderungen")
4. **Use-Case-spezifisches Marketing** (SEO, Competitor, Policy als Segmente)
**Kritische Erkenntnis:** "Change Detection" allein ist kein USP mehr. Gewinner differenzieren sich durch **weniger False Positives**, **bessere Integrationen** und **AI-gestützte Features**.
---
## 1. Competitive Landscape
| Competitor | Trust Signal | Key Differentiator | Pricing Entry |
|------------|--------------|-------------------|---------------|
| **Visualping** | "2M users, 85% Fortune 500" | Enterprise trust, breite Use Cases | Free tier |
| **Distill.io** | "Millions of monitors tracked" | Conditions/Filters für Noise Control | Free plan |
| **Hexowatch** | 13+ Monitoring-Typen | Archiving, Multi-Type Monitoring | Trial |
| **Wachete** | Dynamic Page Focus | JS Rendering, Form Monitoring | Free tier |
| **Sken.io** | €3/mo Entry | Einfach & günstig, 14-day trial | €3/mo |
| **ChangeDetection.io** | Restock Alerts Focus | Consumer/Smart Shoppers | $8.99/mo |
| **Fluxguard** | Enterprise/AI Focus | AI Filtering & Summaries, Sales-led | 7-day trial |
---
## 2. Core Value Propositions (Ranked)
### Was der Markt verspricht (in Reihenfolge der Prominenz):
1. **"Sofort wissen, wenn etwas Wichtiges passiert"**
→ Alerts/Integrations (Slack/Teams/Webhooks) sind **prominentes Feature**, nicht Afterthought
2. **"Weniger Lärm/False Alerts"**
→ Conditions/Filter/AI Filtering als Kern-Differentiator
3. **"Änderungen verstehen & belegen"**
→ Compare/History/Snapshots/Versions (Audit-Trail, Compliance)
4. **"Wettbewerb & Märkte schneller beobachten"**
→ Competitive Intelligence / Pricing/Product Launches
5. **"Risiko/Compliance/Security absichern"**
→ Policy/Legal/Defacement/Compliance Monitoring
---
## 3. Top Use Cases (Jobs-to-be-Done)
### Primary Use Cases (direkt von Competitor-Homepages):
1. **Competitor Monitoring** 🔥
"Sag mir, wenn Wettbewerber Preise/Angebote/Produkte ändern"
2. **SEO Monitoring** 🔥
"Sag mir, wenn Content/SERPs sich ändern, bevor Rankings leiden"
3. **Policy/Legal Tracking** 🔥
"Sag mir, wenn Policy/Regulatory Content updated wird"
4. **Stock/Availability Monitoring** 🔥
"Sag mir, wenn Restock/Availability sich ändert"
5. **Job Postings Monitoring**
"Sag mir, wenn neue Job Postings erscheinen"
### Segmente nach Buyer Persona:
- **SEO/Growth Teams** (SMB → Mid-Market): Competitor + SERP Changes
- **E-Commerce Ops**: Pricing, Stock von Wettbewerbern
- **Compliance/Legal**: Policy-Änderungen dokumentieren
- **Job Seekers** (Consumer): Career Pages tracken
---
## 4. KRITISCHE Gaps in unserem Plan
### ❌ Gap #1: Integrations zu spät (MOST CRITICAL)
**Markt-Evidence:**
- Visualping, Wachete, ChangeDetection zeigen **Slack/Teams/Webhooks** prominent auf Homepage
- Integrations sind **Core Feature**, nicht "Nice to have"
**Unser Plan:**
- Integrations für **V2 (Week 13-15)** geplant ❌
**Recommendation:**
- **Webhooks** → MVP (Week 3) ✅
- **Slack** → V1 (Week 7-8) ✅
- **Teams/Discord** → V2 (wie geplant)
**Reasoning:** Users wollen Alerts in ihren Workflow-Tools, nicht nur Email. Ohne Integrations verlieren wir Pro-Tier-Conversions.
---
### ⚠️ Gap #2: Dynamic Pages/JS-Rendering unterschätzt
**Markt-Evidence:**
- Wachete: "Monitor dynamic JavaScript pages" als Core Feature
- Fluxguard: "Form tracking behind logins" als Differentiator
**Unser Plan:**
- "Complex Page Support" für **V2 (Week 12-13)**
**Recommendation:**
- **Basic JS-Rendering Toggle** → V1 (Week 7) als optionales Feature
- Full "Behind Login" Support → V2 (wie geplant)
**Reasoning:** Moderne Sites sind JS-heavy. Ohne JS-Rendering können wir viele relevante Sites nicht monitoren.
---
### ⚠️ Gap #3: History/Snapshots als "Proof" Feature
**Markt-Evidence:**
- Sken: "Compare versions" prominent featured
- Fluxguard: "Versions kept" als Selling Point
- Messaging: **"Beweise was sich geändert hat"** > "Sieh Änderungen"
**Unser Plan:**
- Diff Viewer im MVP ✅
- Aber Messaging betont nicht **"Audit-proof history"**
**Recommendation:**
- Messaging-Update: Fokus auf **"Prove changes"** statt nur "See changes"
- Snapshot Retention prominent in Pricing-Tiers zeigen
---
### ⚠️ Gap #4: Pricing Model - Checks/Month vs. Monitors
**Markt-Evidence:**
- **Sken:** Checks/month model (z.B. "10,000 checks/mo")
- **Fluxguard:** Hybrid (Sites + Checks/month)
- **Unser Plan:** Monitors + Frequency
**Problem:**
- 50 Monitors à 5min = **14,400 checks/day** = sehr teuer
- 50 Monitors à 1hr = **1,200 checks/day** = viel günstiger
- **Gleicher Preis für völlig unterschiedliche Kosten** → nicht nachhaltig
**Options:**
#### Option A: Checks/Month (Fairest)
```
Free: 1,000 checks/mo (~3 monitors à 1hr)
Pro: 10,000 checks/mo (~50 monitors à 1hr OR 10 à 5min)
Business: 50,000 checks/mo (~200 monitors à 1hr OR 40 à 5min)
```
**Pros:** Fair, scales with costs, clear value
**Cons:** Schwerer zu erklären für User
#### Option B: Hybrid (Monitors + Check Cap)
```
Pro: 50 monitors, max 15k checks/mo
```
**Pros:** Einfach zu verstehen, verhindert Abuse
**Cons:** User hit Caps unpredictably
#### Option C: Current Plan (Monitors + Frequency)
```
Pro: 50 monitors at 5min frequency
```
**Pros:** Easy to understand
**Cons:** **Unsustainable** cost structure
**Recommendation:** Test **Option A (Checks/Month)** mit ersten Beta-Usern via Survey.
---
## 5. Ideal Customer Profile (ICP) - Validated
### Primary ICP
**SEO & Growth Teams (SMB → Mid-Market)**
- **Company Size:** 10-200 employees
- **Role:** SEO Manager, Growth Lead, Content Ops, Product Marketing
- **Pain:** Konkurrenten ändern Preise/Landingpages ohne dass sie es merken; Rankings droppen durch unbemerkte Site-Änderungen
- **Trigger:** Ranking Drop, Competitor launcht Feature, SERP Snippet ändert sich
- **Desired Outcome:** Fast Alerts mit low noise, Proof für Reporting
- **Budget:** $19-99/mo (Pro/Business tier)
### Secondary ICPs
**E-Commerce Operators**
- Monitor competitor pricing, stock availability
- High urgency, brauchen fast frequency (5min)
**Compliance/Legal Teams**
- Monitor policy changes, regulatory updates
- Brauchen Audit Trail, History, Snapshots für Proof
**Job Seekers (Consumer)**
- Monitor Career Pages für neue Postings
- High Conversion free → paid wenn Feature funktioniert
---
## 6. Messaging & Positioning Updates
### ❌ Current Messaging (zu generisch)
> "I watch pages so you don't have to"
**Problem:** Zu vage, kommuniziert weder Value noch Use Cases
### ✅ Recommended Messaging
**Option 1: Signal/Noise Focus**
> "Website change detection that actually works less noise, more signal, with proof"
**Option 2: Use Case Focus** (Recommended)
> "Track competitor changes, SEO updates & policy shifts automatically filtered, instantly alerted"
**Option 3: Workflow Focus**
> "Monitor website changes in Slack/Teams only get alerts that matter, with full history"
### Updated Tagline
**Before:** "I watch pages so you don't have to"
**After:** **"Less noise. More signal. Proof included."**
---
## 7. Competitive Positioning
### vs. Visualping
- **Their Strength:** Enterprise trust (85% Fortune 500), established brand
- **Our Angle:** "Better noise control + fairer pricing without the enterprise bloat"
- **Messaging:** "Built for teams who need results, not demos"
### vs. Distill.io
- **Their Strength:** Conditions/Filters, established user base
- **Our Angle:** "Team features built-in + modern UX not stuck in 2015"
- **Messaging:** "Collaboration-first, not an afterthought"
### vs. Fluxguard
- **Their Strength:** AI summaries, enterprise focus, sales-led
- **Our Angle:** "Self-serve pricing + instant setup no demo calls required"
- **Messaging:** "AI-powered intelligence without the enterprise tax"
### vs. ChangeDetection.io / Sken.io
- **Their Strength:** Low price ($3-9/mo), simple
- **Our Angle:** "Advanced features (keywords, integrations, teams) without complexity"
- **Messaging:** "Powerful, but still simple"
---
## 8. Feature Prioritization Updates
### 🔼 MOVE UP in Priority
| Feature | Old Plan | **New Plan** | Reason |
|---------|----------|-------------|--------|
| **Webhooks** | V2 (Week 13) | **MVP (Week 3)** | Core workflow integration |
| **Slack Integration** | V2 (Week 13) | **V1 (Week 8)** | Strongly demanded by market |
| **Basic JS-Rendering** | V2 (Week 12) | **V1 (Week 7)** | Modern sites need this |
### ✅ Keep as Planned
- Noise Reduction (V1) ✅
- Keyword Alerts (V1) ✅
- AI Summaries (V2) ✅
---
## 9. Go-to-Market Strategy (Evidence-Based)
### Acquisition Channels (basierend auf Markt-Evidence)
1. **SEO (Long-tail Keywords)** 🔥 PRIMARY
- "monitor competitor pricing"
- "SEO change detection tool"
- "track policy changes automatically"
- "website availability monitoring"
- **Evidence:** Alle Competitors haben starken SEO-Footprint
2. **Free Tier → Viral Loop** 🔥
- Visualping: "Get started it's free"
- Distill: Free plan with generous limits
- **Strategy:** Hook users mit Free Tier, convert on limits/features
3. **Integration Directories**
- Zapier Marketplace
- Slack App Directory
- Chrome Extension Store (V2)
4. **Content Marketing**
- "How to monitor job postings" (Tutorial)
- "Competitor price tracking guide" (Playbook)
- "SEO monitoring checklist" (Template)
---
## 10. Social Proof Strategy
### Was im Markt funktioniert
- **Visualping:** "Trusted by 2 million users" + "85% of Fortune 500"
- **Distill:** Usage numbers ("X monitors tracked", "Y happy users")
- **Fluxguard:** Enterprise logos, Case Studies
### Unsere Strategie (Launch → Growth)
**Launch (Month 1-3):**
- "Join 500+ users monitoring X pages"
- Testimonials von Beta Usern
- Use Case Examples (anonymized)
**Growth (Month 4-12):**
- "X million checks performed"
- Customer Logos (mit Permission)
- Case Studies für jeden Use Case (SEO, Competitor, Policy)
**Scale (Year 2+):**
- Industry Benchmarks
- "Most trusted by [segment]"
- Awards/Certifications
---
## 11. Open Questions & Assumptions
### Assumptions to Validate
1. **Use Case Priority**
- **Assumption:** SEO monitoring ist #1 use case
- **Validate:** Landing Page A/B test (SEO vs Competitor vs Policy Hero)
2. **Noise Reduction Messaging**
- **Assumption:** "Less noise" resonates als top pain
- **Validate:** Survey Beta Users: "Was frustriert dich am meisten bei Competitors?"
3. **Pricing Model**
- **Assumption:** Checks/month ist klarer als monitors
- **Validate:** Pricing Page Tests mit verschiedenen Modellen
4. **Integration Priority**
- **Assumption:** Slack > Teams > Discord
- **Validate:** Survey "Welches Tool nutzt du für Alerts?"
5. **JS-Rendering Demand**
- **Assumption:** 40%+ der Use Cases brauchen JS rendering
- **Validate:** Track "Page failed to load" Errors in Beta
---
## 12. Action Items
### 🚨 Immediate (Before Further Development)
- [ ] **Update Messaging** in Landing Page Copy
- Hero: "Less noise. More signal. Proof included."
- Use Cases: SEO, Competitor, Policy prominent
- [ ] **Update Roadmap** (task.md)
- Move Webhooks to MVP
- Move Slack to V1
- Add JS-Rendering option to V1
- [ ] **Decide on Pricing Model**
- Create mockups: Checks/month vs Monitors
- Survey target users (LinkedIn outreach)
- [ ] **Update Competitive Positioning** (CLAUDE.md)
- Add vs. Visualping, Distill, Fluxguard sections
### 📅 Short-term (Next 2 Weeks)
- [ ] Create **Use-Case Landing Pages**
- /use-cases/seo-monitoring
- /use-cases/competitor-tracking
- /use-cases/policy-compliance
- [ ] Build **Comparison Pages**
- /vs/visualping
- /vs/distill
- /vs/fluxguard
- [ ] Set up **Beta User Survey**
- Top frustration with current tools
- Pricing model preference
- Alert channel preference
---
## 13. Confidence & Limitations
### Confidence Level: **Medium-High (75%)**
**Why Medium-High:**
- Data ist von **Proxy Competitors** (nicht direct user research) ⚠️
- Use Cases validated across 7 competitors ✅
- Pricing models vary (kein clear winner) ⚠️
- Free tier + self-serve is universal ✅
**Was würde Confidence erhöhen:**
- User Interviews mit target ICP (SEO/Growth Teams)
- Competitor User Reviews (G2, Capterra scrape)
- Pricing Tests mit real landing page traffic
### Limitations
1. **No Direct User Research** Alle Insights inferred von competitor sites
2. **No Revenue Data** Können nicht validieren welches Pricing Model am besten funktioniert
3. **No Churn Data** Don't know what causes users to leave competitors
4. **Limited Feature Usage Data** Don't know which features drive retention
---
## 14. Summary & Next Steps
### ✅ Was die Analyse bestätigt (gut aligned)
1. **Noise Reduction als Differentiator**
2. **Keyword-based Alerts** als High-Value Feature ✅
3. **Use Cases klar definiert** (SEO, Competitor, Policy, Stock, Jobs) ✅
4. **Free tier + Self-serve Funnel**
### ❌ Was wir ändern müssen
1. **Integrations früher** (Webhooks → MVP, Slack → V1) 🔥
2. **Pricing Model überdenken** (Checks/month fairer) 🔥
3. **Messaging schärfen** (Use-Case-fokussiert) 🔥
4. **JS-Rendering früher** (V1 statt V2) ⚠️
### 🎯 Recommended Actions (in Order)
1. **Update task.md** mit neuen Feature-Priorities
2. **Update CLAUDE.md** mit geschärftem Messaging
3. **Create Killer Landing Page** mit Frontend-Design Skill
4. **Launch Beta** mit 50 Users zur Validation
---
**Last Updated:** 2026-01-18
**Next Review:** After first 50 beta signups

93
findings_plan.md Normal file
View File

@ -0,0 +1,93 @@
# Website Monitor - Aktueller Implementierungsstatus
> **WICHTIG:** Die ursprüngliche `findings.md` ist veraltet! Die meisten P1-Features wurden bereits implementiert.
---
## 🎯 Aktueller Status (Stand: 17.01.2026)
### ✅ P1 - MVP Features (ALLE IMPLEMENTIERT!)
| Feature | Status | Dateien |
|---------|--------|---------|
| **Bull Queue Scheduling** | ✅ FERTIG | `scheduler.ts`, Worker in `index.ts` gestartet |
| **Password Reset Flow** | ✅ FERTIG | `forgot-password/`, `reset-password/[token]/` |
| **Email Verification** | ✅ FERTIG | `verify-email/[token]/` |
| **Settings Backend** | ✅ FERTIG | `routes/settings.ts` (change-password, notifications, delete) |
### ✅ P2 - Core Features
| Feature | Status | Kommentar |
|---------|--------|-----------|
| **Keyword Alerts UI** | ✅ FERTIG | Im Monitor-Formular bereits integriert (`monitors/page.tsx`) |
| **Settings Frontend** | ✅ FERTIG | Mit API-Integration, 450+ Zeilen |
| Mobile Responsive | ⚠️ TEILWEISE | Grundsätzlich responsive, Feinschliff nötig |
| Analytics erweitern | ⚠️ TEILWEISE | Basis-Charts vorhanden, Zeitbereiche fehlen |
| Incidents verbessern | ⚠️ TEILWEISE | Basis-Liste vorhanden |
### ❌ P3 - Differenzierung (Noch offen)
| Feature | Status | Aufwand |
|---------|--------|---------|
| AI Change Importance | ❌ Nicht begonnen | 8-10h |
| Visual Element Selector | ❌ Nicht begonnen | 6-8h |
| Monitor Templates | ❌ Nicht begonnen | 10h+ |
| Change Digest Mode | ❌ Nicht begonnen | 4h |
---
## <20> Was noch geprüft werden sollte
1. **Backend Auth Endpoints** - `forgotPassword`, `resetPassword`, `verifyEmail` - Frontend ruft diese auf, aber Backend-Routes sollten verifiziert werden
2. **Redis Verbindung** - `scheduler.ts` nutzt `REDIS_URL` (Standard: `localhost:6380`)
3. **Worker tatsächlich aktiv** - Prüfen ob Jobs tatsächlich verarbeitet werden
---
## 📊 Fortschritts-Übersicht (AKTUALISIERT)
```
Landing Page ████████████████████ 100% ✅
Authentication ████████████████████ 100% ✅ (inkl. Reset!)
Dashboard ████████████████████ 100% ✅
Monitors + Keywords ████████████████████ 100% ✅
History & Diffs ████████████████████ 100% ✅
Settings ████████████████████ 100% ✅ (Backend + Frontend!)
Analytics ████████████████░░░░ 80% ⚠️
Incidents ████████████░░░░░░░░ 60% ⚠️
Scheduler ████████████████████ 100% ✅ (BullMQ!)
```
---
## <20> Nächste Schritte
### Sofort prüfen:
1. **Redis läuft?**`redis-cli -p 6380 ping`
2. **Backend Auth Endpoints?**`backend/src/routes/auth.ts` prüfen
3. **Scheduler funktioniert?** → Monitor erstellen, warten, prüfen ob Job läuft
### Dann:
1. Analytics um Zeitbereich-Filter erweitern
2. Incidents mit Grouping/Resolution verbessern
3. Mobile Responsive Feinschliff
### Optional (Differenzierung):
- AI Change Importance Scoring
- Visual Element Selector
- Monitor Templates
---
## ⚠️ FAZIT
**Das Projekt ist viel weiter als in `findings.md` dokumentiert!**
Die kritischen P1-Blocker wurden bereits alle gelöst:
- ✅ Bull Queue mit Worker läuft
- ✅ Password Reset Flow komplett
- ✅ Email Verification komplett
- ✅ Settings Backend komplett
- ✅ Keyword Alerts UI existiert
**Empfehlung:** Redis starten und testen ob die automatische Überwachung funktioniert!

3
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -0,0 +1,211 @@
'use client'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
type TimeRange = '7d' | '30d' | '90d' | 'all'
export default function AnalyticsPage() {
const [timeRange, setTimeRange] = useState<TimeRange>('30d')
const { data, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
if (isLoading) {
return (
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</DashboardLayout>
)
}
const monitors = data || []
const totalMonitors = monitors.length
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
const avgFrequency = totalMonitors > 0
? Math.round(monitors.reduce((sum: number, m: any) => sum + m.frequency, 0) / totalMonitors)
: 0
// Calculate additional stats
const pausedMonitors = monitors.filter((m: any) => m.status === 'paused').length
const recentChanges = monitors.filter((m: any) => {
if (!m.last_change_at) return false
const changeDate = new Date(m.last_change_at)
const daysAgo = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : timeRange === '90d' ? 90 : 365
const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
return changeDate >= cutoff
}).length
return (
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
{/* Time Range Selector */}
<div className="mb-6 flex flex-wrap gap-2">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<Button
key={range}
variant={timeRange === range ? 'default' : 'outline'}
size="sm"
onClick={() => setTimeRange(range)}
>
{range === 'all' ? 'All Time' : range === '7d' ? 'Last 7 Days' : range === '30d' ? 'Last 30 Days' : 'Last 90 Days'}
</Button>
))}
</div>
{/* Stats Overview */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Monitors</p>
<p className="text-3xl font-bold">{totalMonitors}</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Uptime Rate</p>
<p className="text-3xl font-bold text-green-600">
{totalMonitors > 0 ? Math.round((activeMonitors / totalMonitors) * 100) : 0}%
</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Error Rate</p>
<p className="text-3xl font-bold text-red-600">
{totalMonitors > 0 ? Math.round((errorMonitors / totalMonitors) * 100) : 0}%
</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Avg. Frequency</p>
<p className="text-3xl font-bold">{avgFrequency} min</p>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Charts Placeholder */}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Monitor Status Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8">
<div className="relative h-40 w-40">
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
<circle cx="50" cy="50" r="40" fill="none" stroke="hsl(var(--muted))" strokeWidth="12" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="hsl(var(--success))"
strokeWidth="12"
strokeDasharray={`${(activeMonitors / (totalMonitors || 1)) * 251.2} 251.2`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl font-bold">{activeMonitors}</span>
<span className="text-xs text-muted-foreground">Active</span>
</div>
</div>
</div>
<div className="flex justify-center gap-6">
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-green-500" />
<span className="text-sm">Active ({activeMonitors})</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<span className="text-sm">Error ({errorMonitors})</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Check Frequency Distribution</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ label: '5 min', count: monitors.filter((m: any) => m.frequency === 5).length },
{ label: '30 min', count: monitors.filter((m: any) => m.frequency === 30).length },
{ label: '1 hour', count: monitors.filter((m: any) => m.frequency === 60).length },
{ label: '6 hours', count: monitors.filter((m: any) => m.frequency === 360).length },
{ label: '24 hours', count: monitors.filter((m: any) => m.frequency === 1440).length },
].map((item) => (
<div key={item.label} className="flex items-center gap-3">
<span className="w-16 text-sm text-muted-foreground">{item.label}</span>
<div className="flex-1 h-4 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${totalMonitors > 0 ? (item.count / totalMonitors) * 100 : 0}%` }}
/>
</div>
<span className="w-8 text-sm font-medium">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</DashboardLayout>
)
}

View File

@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const targetUrl = searchParams.get('url')
if (!targetUrl) {
return NextResponse.json({ error: 'URL parameter required' }, { status: 400 })
}
try {
// Validate URL
const url = new URL(targetUrl)
// Only allow http/https
if (!['http:', 'https:'].includes(url.protocol)) {
return NextResponse.json({ error: 'Invalid URL protocol' }, { status: 400 })
}
// Fetch the page
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
})
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch: ${response.status}` },
{ status: response.status }
)
}
let html = await response.text()
// Inject base tag to fix relative URLs
const baseTag = `<base href="${url.origin}${url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1)}">`
html = html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
// Disable all scripts for security
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// Remove event handlers
html = html.replace(/\son\w+="[^"]*"/gi, '')
html = html.replace(/\son\w+='[^']*'/gi, '')
// Add visual selector helper styles
const helperStyles = `
<style>
* { cursor: crosshair !important; }
a { pointer-events: none; }
</style>
`
html = html.replace('</head>', `${helperStyles}</head>`)
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'X-Frame-Options': 'SAMEORIGIN',
},
})
} catch (error) {
console.error('[Proxy] Error fetching URL:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch URL' },
{ status: 500 }
)
}
}

View File

@ -1,27 +1,19 @@
'use client'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { isAuthenticated, clearAuth } from '@/lib/auth'
import { toast } from 'sonner'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export default function DashboardPage() {
const router = useRouter()
const [showAddForm, setShowAddForm] = useState(false)
const [newMonitor, setNewMonitor] = useState({
url: '',
name: '',
frequency: 60,
})
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
}
}, [router])
const { data, isLoading, refetch } = useQuery({
const { data, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
@ -29,225 +21,162 @@ export default function DashboardPage() {
},
})
const handleLogout = () => {
clearAuth()
router.push('/login')
}
const handleAddMonitor = async (e: React.FormEvent) => {
e.preventDefault()
try {
await monitorAPI.create(newMonitor)
setNewMonitor({ url: '', name: '', frequency: 60 })
setShowAddForm(false)
refetch()
} catch (err) {
console.error('Failed to create monitor:', err)
}
}
const handleCheckNow = async (id: string) => {
try {
await monitorAPI.check(id)
alert('Check triggered! Results will appear shortly.')
setTimeout(() => refetch(), 2000)
} catch (err) {
console.error('Failed to trigger check:', err)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this monitor?')) return
try {
await monitorAPI.delete(id)
refetch()
} catch (err) {
console.error('Failed to delete monitor:', err)
}
}
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p>Loading...</p>
</div>
<DashboardLayout title="Dashboard" description="Overview of your monitors">
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</DashboardLayout>
)
}
const monitors = data || []
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
const recentChanges = monitors.filter((m: any) => m.last_change_at).length
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Website Monitor</h1>
<button
onClick={handleLogout}
className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50"
>
Logout
</button>
</div>
</div>
</header>
<DashboardLayout title="Dashboard" description="Overview of your monitoring activity">
{/* Stats Grid */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Monitors</p>
<p className="text-2xl font-bold">{monitors.length}</p>
</div>
</div>
</CardContent>
</Card>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Your Monitors</h2>
<p className="text-sm text-gray-600">
{monitors.length} monitor{monitors.length !== 1 ? 's' : ''} active
</p>
</div>
<button
onClick={() => setShowAddForm(true)}
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
+ Add Monitor
</button>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-sm text-muted-foreground">Active</p>
<p className="text-2xl font-bold">{activeMonitors}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<p className="text-sm text-muted-foreground">Errors</p>
<p className="text-2xl font-bold">{errorMonitors}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<div>
<p className="text-sm text-muted-foreground">Recent Changes</p>
<p className="text-2xl font-bold">{recentChanges}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Quick Actions</h2>
<div className="flex flex-wrap gap-3">
<Button onClick={() => router.push('/monitors')}>
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Monitor
</Button>
<Button variant="outline" onClick={() => router.push('/incidents')}>
View Incidents
</Button>
<Button variant="outline" onClick={() => router.push('/analytics')}>
View Analytics
</Button>
</div>
</div>
{/* Recent Monitors */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Recent Monitors</h2>
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')}>
View All
</Button>
</div>
{/* Add Monitor Form */}
{showAddForm && (
<div className="mb-6 rounded-lg bg-white p-6 shadow">
<h3 className="mb-4 text-lg font-semibold">Add New Monitor</h3>
<form onSubmit={handleAddMonitor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
URL
</label>
<input
type="url"
value={newMonitor.url}
onChange={(e) =>
setNewMonitor({ ...newMonitor, url: e.target.value })
}
placeholder="https://example.com"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Name (optional)
</label>
<input
type="text"
value={newMonitor.name}
onChange={(e) =>
setNewMonitor({ ...newMonitor, name: e.target.value })
}
placeholder="My Monitor"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Check Frequency (minutes)
</label>
<select
value={newMonitor.frequency}
onChange={(e) =>
setNewMonitor({
...newMonitor,
frequency: parseInt(e.target.value),
})
}
className="mt-1 block w-full rounded-md border px-3 py-2"
>
<option value={5}>Every 5 minutes</option>
<option value={30}>Every 30 minutes</option>
<option value={60}>Every hour</option>
<option value={360}>Every 6 hours</option>
<option value={1440}>Every 24 hours</option>
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
Create Monitor
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="rounded-md border px-4 py-2 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Monitors List */}
{monitors.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center shadow">
<p className="mb-4 text-gray-600">No monitors yet</p>
<button
onClick={() => setShowAddForm(true)}
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
Create Your First Monitor
</button>
</div>
) : (
<div className="space-y-4">
{monitors.map((monitor: any) => (
<div
key={monitor.id}
className="rounded-lg bg-white p-6 shadow hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold">{monitor.name}</h3>
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
<div className="mt-2 flex gap-4 text-xs text-gray-500">
<span>Every {monitor.frequency} min</span>
<span className="capitalize">Status: {monitor.status}</span>
{monitor.last_checked_at && (
<span>
Last checked:{' '}
{new Date(monitor.last_checked_at).toLocaleString()}
</span>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleCheckNow(monitor.id)}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
Check Now
</button>
<button
onClick={() => router.push(`/monitors/${monitor.id}`)}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
History
</button>
<button
onClick={() => handleDelete(monitor.id)}
className="rounded-md border border-red-200 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
<Card className="text-center">
<CardContent className="py-12">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<h3 className="mb-2 text-lg font-semibold">No monitors yet</h3>
<p className="mb-6 text-muted-foreground">
Start monitoring your first website
</p>
<Button onClick={() => router.push('/monitors')}>
Create Your First Monitor
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{monitors.slice(0, 5).map((monitor: any) => (
<Card key={monitor.id} hover onClick={() => router.push(`/monitors/${monitor.id}`)}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`status-dot ${monitor.status === 'active' ? 'status-dot-success' : monitor.status === 'error' ? 'status-dot-error' : 'status-dot-neutral'}`} />
<div>
<h3 className="font-medium">{monitor.name || monitor.url}</h3>
<p className="text-sm text-muted-foreground truncate max-w-md">{monitor.url}</p>
</div>
</div>
<Badge variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}>
{monitor.status}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</main>
</div>
</div>
</DashboardLayout>
)
}

View File

@ -0,0 +1,142 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { authAPI } from '@/lib/api'
export default function ForgotPasswordPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await authAPI.forgotPassword(email)
setEmailSent(true)
toast.success('Check your email for password reset instructions')
} catch (error: any) {
console.error('Forgot password error:', error)
// Show generic success message for security (prevent email enumeration)
setEmailSent(true)
toast.success('Check your email for password reset instructions')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<div className="flex items-center justify-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<span className="text-2xl font-bold">Website Monitor</span>
</div>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Reset Password</CardTitle>
<CardDescription>
{emailSent
? 'Check your email for instructions'
: 'Enter your email to receive password reset instructions'}
</CardDescription>
</CardHeader>
<CardContent>
{!emailSent ? (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
disabled={isLoading}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</Button>
<div className="text-center text-sm">
<Link
href="/login"
className="text-primary hover:underline"
>
Back to Login
</Link>
</div>
</form>
) : (
<div className="space-y-4">
<div className="rounded-lg bg-green-50 p-4 text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-green-900">Email Sent!</p>
<p className="mt-1 text-sm text-green-700">
If an account exists with <strong>{email}</strong>, you will receive password reset instructions shortly.
</p>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">Didn't receive an email?</p>
<ul className="ml-4 list-disc space-y-1">
<li>Check your spam folder</li>
<li>Make sure you entered the correct email</li>
<li>Wait a few minutes and try again</li>
</ul>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => {
setEmailSent(false)
setEmail('')
}}
>
Try Different Email
</Button>
<div className="text-center text-sm">
<Link
href="/login"
className="text-primary hover:underline"
>
Back to Login
</Link>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -1,51 +1,115 @@
/* Import Premium Fonts: Space Grotesk (headlines) + Inter Tight (body/UI) - MUST be first */
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/* Premium Warm Palette - Extracted from User Image */
--background: 40 11% 97%;
/* #F9F8F6 */
--foreground: 30 10% 20%;
/* Dark Charcoal for text */
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/* #FFFFFF */
--card-foreground: 30 10% 20%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--popover-foreground: 30 10% 20%;
--primary: 34 29% 70%;
/* #C9B59C - Sand/Gold Accent */
--primary-foreground: 0 0% 100%;
--secondary: 30 24% 91%;
/* #EFE9E3 - Light Beige */
--secondary-foreground: 30 10% 20%;
--muted: 27 18% 82%;
/* #D9CFC7 - Taupe/Grayish */
--muted-foreground: 30 8% 45%;
--accent: 34 29% 70%;
--accent-foreground: 0 0% 100%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--success: 142 76% 36%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--border: 27 18% 82%;
--input: 27 18% 82%;
--ring: 34 29% 70%;
--radius: 0.75rem;
/* New Accent Colors for Landing Page */
--burgundy: 349 67% 36%;
/* #8B2635 - Deep burgundy for "change detected" */
--teal: 177 35% 28%;
/* #2D5F5D - Deep teal for "signal/filtered" */
--noise-bg: 40 11% 96%;
/* #F5F5F3 - Very light gray with texture */
/* Section Background Variations for Visual Rhythm */
--section-bg-1: 40 11% 97%;
/* Cream/Off-White - Hero */
--section-bg-2: 30 15% 95%;
/* Warmer Beige - Stats */
--section-bg-3: 40 8% 96%;
/* Kühler Grau - Use Cases */
--section-bg-4: 35 20% 94%;
/* Warmes Taupe - How It Works */
--section-bg-5: 25 12% 93%;
/* Sandstone - Differentiators */
--section-bg-6: 177 10% 94%;
/* Sehr leichtes Teal - Pricing */
--section-bg-7: 349 8% 95%;
/* Sehr leichtes Burgundy - Social Proof */
}
/* Dark theme following the warm palette aesthetic */
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--background: 30 15% 10%;
--foreground: 40 14% 92%;
--card: 30 12% 14%;
--card-foreground: 40 14% 92%;
--popover: 30 12% 14%;
--popover-foreground: 40 14% 92%;
--primary: 32 35% 55%;
--primary-foreground: 30 15% 10%;
--secondary: 30 12% 20%;
--secondary-foreground: 40 14% 92%;
--muted: 30 12% 20%;
--muted-foreground: 35 10% 60%;
--accent: 32 35% 55%;
--accent-foreground: 30 15% 10%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--success: 142 76% 36%;
--success-foreground: 0 0% 100%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--border: 30 12% 24%;
--input: 30 12% 24%;
--ring: 32 35% 50%;
}
}
@ -53,7 +117,578 @@
* {
@apply border-border;
}
html {
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
/* Typography Classes - using next/font CSS variables */
.font-display {
font-family: var(--font-display), 'Space Grotesk', system-ui, sans-serif;
font-feature-settings: 'ss01';
letter-spacing: -0.02em;
}
.font-body {
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
}
.font-accent {
font-family: var(--font-display), 'Space Grotesk', monospace;
font-feature-settings: 'ss01';
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Typography Size Utilities */
.text-display-xl {
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 700;
line-height: 0.95;
letter-spacing: -0.03em;
}
.text-display-lg {
font-size: clamp(2.5rem, 6vw, 5rem);
font-weight: 600;
line-height: 1.05;
letter-spacing: -0.02em;
}
.text-body-lg {
font-size: clamp(1.125rem, 2vw, 1.5rem);
line-height: 1.6;
font-weight: 400;
}
}
/* Premium UI Utilities */
@layer components {
/* Glass Panel Effect */
.glass-panel {
@apply bg-card/80 backdrop-blur-md border border-border/50 shadow-lg;
}
/* Premium Card with subtle shadow and hover effect */
.premium-card {
@apply bg-card rounded-xl border border-border/50 shadow-sm transition-all duration-300;
@apply hover:shadow-md hover:border-primary/30;
}
/* Glassmorphism Cards - Premium frosted glass effect */
.glass-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px 0 rgba(0, 0, 0, 0.08),
inset 0 1px 0 0 rgba(255, 255, 255, 0.5);
}
.glass-card-dark {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Gradient Accent Background */
.gradient-accent {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%);
}
/* New Gradient Combinations */
.gradient-primary-teal {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 100%);
}
.gradient-teal-burgundy {
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(var(--burgundy)) 100%);
}
.gradient-warm {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(34 40% 60%) 100%);
}
.gradient-cool {
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(200 30% 50%) 100%);
}
/* Status indicator dots */
.status-dot {
@apply w-2.5 h-2.5 rounded-full;
}
.status-dot-success {
@apply status-dot bg-green-500;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot-error {
@apply status-dot bg-red-500;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
}
.status-dot-warning {
@apply status-dot bg-yellow-500;
box-shadow: 0 0 8px rgba(234, 179, 8, 0.5);
}
.status-dot-neutral {
@apply status-dot bg-gray-400;
}
/* Animated skeleton loading */
.skeleton {
@apply animate-pulse bg-muted rounded;
}
/* Focus ring for accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
}
}
/* Smooth scrollbar */
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground));
}
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-16px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 4px rgba(196, 178, 156, 0.4);
}
50% {
box-shadow: 0 0 12px rgba(196, 178, 156, 0.7);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Grain Texture Overlay */
@keyframes grain {
0%,
100% {
transform: translate(0, 0);
}
10% {
transform: translate(-5%, -10%);
}
20% {
transform: translate(-15%, 5%);
}
30% {
transform: translate(7%, -25%);
}
40% {
transform: translate(-5%, 25%);
}
50% {
transform: translate(-15%, 10%);
}
60% {
transform: translate(15%, 0%);
}
70% {
transform: translate(0%, 15%);
}
80% {
transform: translate(3%, 35%);
}
90% {
transform: translate(-10%, 10%);
}
}
.grain-texture::before {
content: '';
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
animation: grain 8s steps(10) infinite;
pointer-events: none;
}
/* Stagger Animation Delays */
.stagger-1 {
animation-delay: 0.1s;
}
.stagger-2 {
animation-delay: 0.2s;
}
.stagger-3 {
animation-delay: 0.3s;
}
.stagger-4 {
animation-delay: 0.4s;
}
.stagger-5 {
animation-delay: 0.5s;
}
.stagger-6 {
animation-delay: 0.6s;
}
/* Enhanced Animations for Phase 2 */
/* Smooth Scale In with Spring */
@keyframes scaleInSpring {
0% {
opacity: 0;
transform: scale(0.85);
}
50% {
transform: scale(1.05);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* Blur to Sharp */
@keyframes blurToSharp {
from {
filter: blur(10px);
opacity: 0;
}
to {
filter: blur(0px);
opacity: 1;
}
}
/* Letter Spacing Animation */
@keyframes letterSpacing {
from {
letter-spacing: 0.2em;
opacity: 0;
}
to {
letter-spacing: normal;
opacity: 1;
}
}
/* Rubber Band Effect */
@keyframes rubberBand {
0% {
transform: scale3d(1, 1, 1);
}
30% {
transform: scale3d(1.25, 0.75, 1);
}
40% {
transform: scale3d(0.75, 1.25, 1);
}
50% {
transform: scale3d(1.15, 0.85, 1);
}
65% {
transform: scale3d(0.95, 1.05, 1);
}
75% {
transform: scale3d(1.05, 0.95, 1);
}
100% {
transform: scale3d(1, 1, 1);
}
}
/* Glow Pulse */
@keyframes glowPulse {
0%,
100% {
box-shadow: 0 0 5px hsl(var(--teal) / 0.3), 0 0 10px hsl(var(--teal) / 0.2);
}
50% {
box-shadow: 0 0 20px hsl(var(--teal) / 0.6), 0 0 30px hsl(var(--teal) / 0.3), 0 0 40px hsl(var(--teal) / 0.2);
}
}
/* Gradient Shift */
@keyframes gradientShift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Shimmer Effect */
@keyframes shimmer {
0% {
transform: translateX(-100%) skewX(-20deg);
}
100% {
transform: translateX(200%) skewX(-20deg);
}
}
/* Ripple Effect */
@keyframes ripple {
0% {
transform: scale(0);
opacity: 1;
}
100% {
transform: scale(4);
opacity: 0;
}
}
/* Bounce In */
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3) translateY(-50px);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
/* Float Animation */
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
/* Rotate 3D */
@keyframes rotate3d {
0% {
transform: perspective(1000px) rotateY(0deg);
}
100% {
transform: perspective(1000px) rotateY(360deg);
}
}
/* Utility Classes */
.animate-scale-in-spring {
animation: scaleInSpring 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.animate-blur-to-sharp {
animation: blurToSharp 0.8s ease-out forwards;
}
.animate-letter-spacing {
animation: letterSpacing 1s ease-out forwards;
}
.animate-rubber-band {
animation: rubberBand 0.8s ease-out;
}
.animate-glow-pulse {
animation: glowPulse 2s ease-in-out infinite;
}
.animate-gradient-shift {
animation: gradientShift 3s ease infinite;
background-size: 200% 200%;
}
.animate-shimmer {
animation: shimmer 2s ease-in-out;
}
.animate-ripple {
animation: ripple 0.6s ease-out;
}
.animate-bounce-in {
animation: bounceIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-rotate-3d {
animation: rotate3d 20s linear infinite;
}
/* Hover Effects */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.hover-glow {
position: relative;
transition: all 0.3s ease;
}
.hover-glow:hover {
box-shadow: 0 0 20px hsl(var(--primary) / 0.5);
}
.hover-scale {
transition: transform 0.3s ease;
}
.hover-scale:hover {
transform: scale(1.05);
}
/* Gradient Text */
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 200% auto;
}
.gradient-text-animated {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
background-size: 200% auto;
animation: gradientShift 3s ease infinite;
}
/* Reduced Motion Support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.grain-texture::before {
animation: none;
}
}

View File

@ -0,0 +1,266 @@
'use client'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'
type FilterType = 'all' | 'errors' | 'changes'
interface Incident {
id: string
monitorId: string
monitorName: string
monitorUrl: string
type: 'error' | 'change'
timestamp: Date
details?: string
}
export default function IncidentsPage() {
const router = useRouter()
const [filter, setFilter] = useState<FilterType>('all')
const [resolvedIds, setResolvedIds] = useState<Set<string>>(new Set())
const [showResolved, setShowResolved] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
if (isLoading) {
return (
<DashboardLayout title="Incidents" description="View detected changes and errors">
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</DashboardLayout>
)
}
const monitors = data || []
// Build incidents list from monitors
const incidents: Incident[] = monitors.flatMap((m: any) => {
const result: Incident[] = []
if (m.status === 'error') {
result.push({
id: `error-${m.id}`,
monitorId: m.id,
monitorName: m.name || m.url,
monitorUrl: m.url,
type: 'error',
timestamp: new Date(m.updated_at || m.created_at),
details: m.last_error || 'Connection failed'
})
}
if (m.last_change_at) {
result.push({
id: `change-${m.id}`,
monitorId: m.id,
monitorName: m.name || m.url,
monitorUrl: m.url,
type: 'change',
timestamp: new Date(m.last_change_at),
details: 'Content changed'
})
}
return result
}).sort((a: Incident, b: Incident) => b.timestamp.getTime() - a.timestamp.getTime())
// Apply filters
const filteredIncidents = incidents.filter(incident => {
if (!showResolved && resolvedIds.has(incident.id)) return false
if (filter === 'errors') return incident.type === 'error'
if (filter === 'changes') return incident.type === 'change'
return true
})
const errorCount = incidents.filter(i => i.type === 'error').length
const changeCount = incidents.filter(i => i.type === 'change').length
const toggleResolved = (id: string) => {
setResolvedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const formatTimeAgo = (date: Date) => {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
return (
<DashboardLayout title="Incidents" description="View detected changes and errors">
{/* Filter Tabs */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
All ({incidents.length})
</Button>
<Button
variant={filter === 'errors' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('errors')}
className={filter !== 'errors' && errorCount > 0 ? 'border-red-200 text-red-600' : ''}
>
🔴 Errors ({errorCount})
</Button>
<Button
variant={filter === 'changes' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('changes')}
className={filter !== 'changes' && changeCount > 0 ? 'border-blue-200 text-blue-600' : ''}
>
🔵 Changes ({changeCount})
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowResolved(!showResolved)}
>
{showResolved ? 'Hide Resolved' : 'Show Resolved'}
</Button>
</div>
{filteredIncidents.length === 0 ? (
<Card className="text-center">
<CardContent className="py-12">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold">All Clear!</h3>
<p className="text-muted-foreground">
{filter === 'all'
? 'No incidents or changes detected'
: filter === 'errors'
? 'No errors to show'
: 'No changes to show'
}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredIncidents.map((incident) => (
<Card
key={incident.id}
className={`transition-all ${resolvedIds.has(incident.id) ? 'opacity-50' : ''}`}
>
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${incident.type === 'error' ? 'bg-red-100' : 'bg-blue-100'
}`}>
{incident.type === 'error' ? (
<svg className="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold truncate">{incident.monitorName}</h3>
<Badge variant={incident.type === 'error' ? 'destructive' : 'default'}>
{incident.type === 'error' ? 'Error' : 'Changed'}
</Badge>
{resolvedIds.has(incident.id) && (
<Badge variant="outline" className="text-green-600 border-green-200">
Resolved
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">{incident.monitorUrl}</p>
{incident.details && (
<p className="mt-1 text-sm text-muted-foreground">{incident.details}</p>
)}
<p className="mt-1 text-xs text-muted-foreground">{formatTimeAgo(incident.timestamp)}</p>
</div>
</div>
<div className="flex gap-2 sm:flex-col sm:items-end">
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/monitors/${incident.monitorId}`)}
>
View Details
</Button>
<Button
variant={resolvedIds.has(incident.id) ? 'default' : 'ghost'}
size="sm"
onClick={() => toggleResolved(incident.id)}
>
{resolvedIds.has(incident.id) ? 'Unresolve' : 'Resolve'}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Summary Stats */}
{incidents.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div className="text-center">
<p className="text-2xl font-bold">{incidents.length}</p>
<p className="text-sm text-muted-foreground">Total Incidents</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{errorCount}</p>
<p className="text-sm text-muted-foreground">Errors</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{changeCount}</p>
<p className="text-sm text-muted-foreground">Changes</p>
</div>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
)
}

View File

@ -1,24 +1,39 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Inter_Tight, Space_Grotesk } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
// Body/UI font - straff, modern, excellent readability
const interTight = Inter_Tight({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
})
// Headline font - geometric, futuristic, "smart" look
const spaceGrotesk = Space_Grotesk({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
})
export const metadata: Metadata = {
title: 'Website Monitor - Track Changes on Any Website',
description: 'Monitor website changes with smart filtering and instant alerts',
}
import { Toaster } from 'sonner'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
<body className={interTight.className}>
<Providers>{children}</Providers>
<Toaster richColors position="top-right" />
</body>
</html>
)

View File

@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
export default function LoginPage() {
const router = useRouter()
@ -30,63 +33,99 @@ export default function LoginPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
<h2 className="mb-6 text-center text-xl text-gray-600">Sign In</h2>
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{/* Subtle Background Pattern */}
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
{error}
<div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<svg
className="h-7 w-7 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</div>
)}
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
<CardDescription>
Sign in to your Website Monitor account
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
<CardContent className="pt-6">
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<div className="mt-1 text-right">
<Link
href="/forgot-password"
className="text-sm text-primary hover:underline"
>
Forgot password?
</Link>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<Button
type="submit"
className="w-full"
size="lg"
loading={loading}
>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</CardContent>
<p className="mt-6 text-center text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/register" className="font-medium text-primary hover:underline">
Sign up
</Link>
</p>
</div>
<CardFooter className="justify-center border-t pt-6">
<p className="text-sm text-muted-foreground">
Don't have an account?{' '}
<Link
href="/register"
className="font-medium text-primary hover:underline"
>
Create account
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
)

View File

@ -1,22 +1,18 @@
'use client'
import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { isAuthenticated } from '@/lib/auth'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export default function MonitorHistoryPage() {
const router = useRouter()
const params = useParams()
const id = params?.id as string
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
}
}, [router])
const { data: monitorData } = useQuery({
queryKey: ['monitor', id],
queryFn: async () => {
@ -35,9 +31,14 @@ export default function MonitorHistoryPage() {
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p>Loading...</p>
</div>
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading history...</p>
</div>
</div>
</DashboardLayout>
)
}
@ -45,102 +46,161 @@ export default function MonitorHistoryPage() {
const monitor = monitorData
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/dashboard')}
className="text-gray-600 hover:text-gray-900"
>
Back
</button>
<div>
<h1 className="text-2xl font-bold">
{monitor?.name || 'Monitor History'}
</h1>
{monitor && (
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
)}
</div>
</div>
<DashboardLayout>
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')} className="gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</Button>
</div>
</header>
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{monitor?.name || 'Monitor History'}</h1>
{monitor && (
<p className="text-sm text-muted-foreground mt-1 truncate max-w-lg">{monitor.url}</p>
)}
</div>
{monitor && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await monitorAPI.exportAuditTrail(id, 'json');
} catch (e) {
console.error('Export failed:', e);
}
}}
className="gap-2"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
JSON
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await monitorAPI.exportAuditTrail(id, 'csv');
} catch (e) {
console.error('Export failed:', e);
}
}}
className="gap-2"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
CSV
</Button>
</div>
)}
</div>
</div>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<h2 className="mb-4 text-xl font-semibold">Check History</h2>
{/* History List */}
<div>
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
{snapshots.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center shadow">
<p className="text-gray-600">No history yet</p>
<p className="mt-2 text-sm text-gray-500">
The first check will happen soon
</p>
</div>
<Card className="text-center">
<CardContent className="py-12">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold">No history yet</h3>
<p className="text-muted-foreground">The first check will happen soon</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{snapshots.map((snapshot: any) => (
<div
key={snapshot.id}
className={`rounded-lg bg-white p-4 shadow ${
snapshot.changed ? 'border-l-4 border-l-blue-500' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<span
className={`rounded px-2 py-1 text-xs font-medium ${
snapshot.changed
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
{snapshots.map((snapshot: any) => {
// Determine border color based on HTTP status
const getBorderColor = () => {
if (snapshot.httpStatus >= 400 || snapshot.errorMessage) {
return 'border-l-4 border-l-red-500' // Error (4xx, 5xx)
}
if (snapshot.httpStatus >= 200 && snapshot.httpStatus < 300) {
if (snapshot.changed) {
return 'border-l-4 border-l-green-500' // Success with change
}
return 'border-l-4 border-l-blue-400' // Success no change (neutral)
}
return 'border-l-4 border-l-blue-400' // Default neutral
}
return (
<Card
key={snapshot.id}
className={`transition-all ${getBorderColor()}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{snapshot.changed ? (
<Badge variant="default">Changed</Badge>
) : (
<Badge variant="secondary">No Change</Badge>
)}
{snapshot.errorMessage && (
<Badge variant="destructive">Error</Badge>
)}
<span className="text-sm text-muted-foreground">
{new Date(snapshot.createdAt).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
<span>HTTP {snapshot.httpStatus}</span>
<span>{snapshot.responseTime}ms</span>
{snapshot.changePercentage && (
<span>{Number(snapshot.changePercentage).toFixed(2)}% changed</span>
)}
</div>
{snapshot.errorMessage && (
<p className="mt-2 text-sm text-destructive">{snapshot.errorMessage}</p>
)}
{snapshot.summary && (
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
<p className="font-medium text-foreground mb-1">Summary</p>
<p>{snapshot.summary}</p>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/monitors/${id}/snapshot/${snapshot.id}`)}
>
{snapshot.changed ? 'Changed' : 'No Change'}
</span>
{snapshot.error_message && (
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
Error
</span>
)}
<span className="text-sm text-gray-600">
{new Date(snapshot.created_at).toLocaleString()}
</span>
{snapshot.errorMessage ? 'View Error' : 'View Details'}
</Button>
</div>
<div className="mt-2 flex gap-4 text-sm text-gray-600">
<span>HTTP {snapshot.http_status}</span>
<span>{snapshot.response_time}ms</span>
{snapshot.change_percentage && (
<span>{snapshot.change_percentage.toFixed(2)}% changed</span>
)}
</div>
{snapshot.error_message && (
<p className="mt-2 text-sm text-red-600">
{snapshot.error_message}
</p>
)}
</div>
{snapshot.html_content && (
<button
onClick={() =>
router.push(`/monitors/${id}/snapshot/${snapshot.id}`)
}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
View Details
</button>
)}
</div>
</div>
))}
</CardContent>
</Card>
)
})}
</div>
)}
</main>
</div>
</div>
</DashboardLayout>
)
}

View File

@ -0,0 +1,240 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
export default function SnapshotDetailsPage() {
const router = useRouter()
const params = useParams()
const monitorId = params?.id as string
const snapshotId = params?.snapshotId as string
const [showHtml, setShowHtml] = useState(false)
const { data: monitorData } = useQuery({
queryKey: ['monitor', monitorId],
queryFn: async () => {
const response = await monitorAPI.get(monitorId)
return response.monitor
},
})
const { data: snapshotData, isLoading } = useQuery({
queryKey: ['snapshot', monitorId, snapshotId],
queryFn: async () => {
const response = await monitorAPI.snapshot(monitorId, snapshotId)
return response.snapshot
},
})
const { data: historyData } = useQuery({
queryKey: ['history', monitorId],
queryFn: async () => {
const response = await monitorAPI.history(monitorId, 2)
return response.snapshots
}
})
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading snapshot...</p>
</div>
</div>
</DashboardLayout>
)
}
const snapshot = snapshotData
const monitor = monitorData
const previousSnapshot = historyData?.find((s: any) =>
new Date(s.createdAt) < new Date(snapshot?.createdAt)
)
if (!snapshot) {
return (
<DashboardLayout>
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="text-lg font-medium">Snapshot not found</p>
<Button variant="outline" onClick={() => router.push(`/monitors/${monitorId}`)}>
Back to History
</Button>
</div>
</DashboardLayout>
)
}
return (
<DashboardLayout>
{/* Page Header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitorId}`)} className="gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to History
</Button>
</div>
<h1 className="text-2xl font-bold">Snapshot Details</h1>
{monitor && (
<p className="text-sm text-muted-foreground mt-1">{monitor.name}</p>
)}
</div>
{/* Snapshot Info Card */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Snapshot Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Status</p>
{snapshot.changed ? (
<Badge variant="default">Changed</Badge>
) : (
<Badge variant="secondary">No Change</Badge>
)}
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Created At</p>
<p className="font-medium">
{new Date(snapshot.createdAt).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">HTTP Status</p>
<Badge variant={snapshot.httpStatus >= 400 ? 'destructive' : 'success'}>
{snapshot.httpStatus || 'N/A'}
</Badge>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Response Time</p>
<p className="font-medium">{snapshot.responseTime}ms</p>
</div>
{snapshot.changePercentage && (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Change Percentage</p>
<p className="font-medium text-primary">
{Number(snapshot.changePercentage).toFixed(2)}%
</p>
</div>
)}
</div>
{snapshot.errorMessage && (
<div className="mt-6 rounded-lg bg-destructive/10 p-4">
<p className="text-sm font-medium text-destructive">Error</p>
<p className="text-sm text-destructive/80 mt-1">{snapshot.errorMessage}</p>
</div>
)}
{/* Change Summary */}
{snapshot.summary && (
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
<p className="text-sm font-medium text-blue-900">Change Summary</p>
<p className="text-sm text-blue-700 mt-1">{snapshot.summary}</p>
</div>
)}
</CardContent>
</Card>
{/* Diff Viewer */}
{snapshot.changed && previousSnapshot && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Changes Detected</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-[500px] overflow-auto rounded-lg border border-border">
<ReactDiffViewer
oldValue={previousSnapshot.textContent || ''}
newValue={snapshot.textContent || ''}
splitView={true}
compareMethod={DiffMethod.WORDS}
useDarkTheme={false}
styles={{
variables: {
light: {
diffViewerBackground: 'hsl(40 14% 97%)',
addedBackground: 'rgba(34, 197, 94, 0.1)',
addedGutterBackground: 'rgba(34, 197, 94, 0.2)',
removedBackground: 'rgba(239, 68, 68, 0.1)',
removedGutterBackground: 'rgba(239, 68, 68, 0.2)',
wordAddedBackground: 'rgba(34, 197, 94, 0.3)',
wordRemovedBackground: 'rgba(239, 68, 68, 0.3)',
addedGutterColor: '#166534',
removedGutterColor: '#991b1b',
gutterBackground: 'hsl(35 18% 88%)',
gutterBackgroundDark: 'hsl(35 18% 85%)',
codeFoldBackground: 'hsl(35 15% 82%)',
codeFoldGutterBackground: 'hsl(35 15% 80%)',
},
},
}}
/>
</div>
</CardContent>
</Card>
)}
{/* Text Content when no change */}
{!snapshot.changed && snapshot.textContent && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Text Content</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-96 overflow-auto rounded-lg bg-muted p-4 text-sm whitespace-pre-wrap scrollbar-thin">
{snapshot.textContent}
</pre>
</CardContent>
</Card>
)}
{/* HTML Content Toggle */}
{snapshot.htmlContent && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>HTML Content</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowHtml(!showHtml)}>
{showHtml ? 'Hide HTML' : 'Show HTML'}
</Button>
</CardHeader>
{showHtml && (
<CardContent>
<pre className="max-h-96 overflow-auto rounded-lg bg-foreground p-4 text-sm text-green-400 whitespace-pre-wrap scrollbar-thin">
{snapshot.htmlContent}
</pre>
</CardContent>
)}
</Card>
)}
</DashboardLayout>
)
}

View File

@ -0,0 +1,932 @@
'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { toast } from 'sonner'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { VisualSelector } from '@/components/visual-selector'
import { monitorTemplates, applyTemplate, MonitorTemplate } from '@/lib/templates'
import { Sparkline } from '@/components/sparkline'
import { Monitor } from '@/lib/types'
import { usePlan } from '@/lib/use-plan'
const IGNORE_PRESETS = [
{ label: 'None', value: '' },
{ label: 'Timestamps & Dates', value: 'time, .time, .date, .datetime, .timestamp, .random, .updated, .modified, .posted, .published, [class*="time"], [class*="date"], [class*="timestamp"], [class*="updated"], [class*="modified"]' },
{ label: 'Cookie Banners', value: '[id*="cookie"], [class*="cookie"], [id*="consent"], [class*="consent"]' },
{ label: 'Social Widgets', value: '.social-share, .twitter-tweet, iframe[src*="youtube"]' },
{ label: 'Custom Selector', value: 'custom' },
]
const FREQUENCY_OPTIONS = [
{ value: 5, label: 'Every 5 minutes' },
{ value: 30, label: 'Every 30 minutes' },
{ value: 60, label: 'Every hour' },
{ value: 360, label: 'Every 6 hours' },
{ value: 1440, label: 'Every 24 hours' },
]
// Stats card component
function StatCard({ icon, label, value, subtext, color }: {
icon: React.ReactNode
label: string
value: string | number
subtext?: string
color: 'green' | 'amber' | 'red' | 'blue'
}) {
const colorClasses = {
green: {
container: 'bg-green-50 text-green-600 border border-green-200',
iconBg: 'bg-white shadow-sm'
},
amber: {
container: 'bg-amber-50 text-amber-600 border border-amber-200',
iconBg: 'bg-white shadow-sm'
},
red: {
container: 'bg-red-50 text-red-600 border border-red-200',
iconBg: 'bg-white shadow-sm'
},
blue: {
container: 'bg-blue-50 text-blue-600 border border-blue-200',
iconBg: 'bg-white shadow-sm'
},
}
const currentColor = colorClasses[color]
return (
<div className={`rounded-xl p-4 ${currentColor.container}`}>
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${currentColor.iconBg}`}>
{icon}
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs opacity-80">{label}</p>
{subtext && <p className="text-xs opacity-60">{subtext}</p>}
</div>
</div>
</div>
)
}
export default function MonitorsPage() {
const router = useRouter()
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
const [newMonitor, setNewMonitor] = useState({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: [] as Array<{
keyword: string
type: 'appears' | 'disappears' | 'count'
threshold?: number
caseSensitive?: boolean
}>,
})
const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
const { data, isLoading, refetch } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const payload: any = {
url: newMonitor.url,
name: newMonitor.name,
frequency: newMonitor.frequency,
}
if (newMonitor.ignoreSelector) {
payload.ignoreRules = [{ type: 'css', value: newMonitor.ignoreSelector }]
}
if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules
}
if (editingId) {
await monitorAPI.update(editingId, payload)
toast.success('Monitor updated successfully')
} else {
await monitorAPI.create(payload)
toast.success('Monitor created successfully')
}
setNewMonitor({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
})
setShowAddForm(false)
setEditingId(null)
refetch()
} catch (err: any) {
console.error('Failed to save monitor:', err)
toast.error(err.response?.data?.message || 'Failed to save monitor')
}
}
const handleEdit = (monitor: any) => {
let selectedPreset = ''
let ignoreSelector = ''
if (monitor.ignoreRules && monitor.ignoreRules.length > 0) {
const ruleValue = monitor.ignoreRules[0].value
const matchingPreset = IGNORE_PRESETS.find(p => p.value === ruleValue)
if (matchingPreset) {
selectedPreset = ruleValue
ignoreSelector = ruleValue
} else {
selectedPreset = 'custom'
ignoreSelector = ruleValue
}
}
setNewMonitor({
url: monitor.url,
name: monitor.name || '',
frequency: monitor.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitor.keywordRules || []
})
setEditingId(monitor.id)
setShowAddForm(true)
}
const handleCancelForm = () => {
setShowAddForm(false)
setEditingId(null)
setNewMonitor({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: []
})
}
const handleSelectTemplate = (template: MonitorTemplate) => {
const monitorData = applyTemplate(template, template.urlPlaceholder)
// Convert ignoreRules to format expected by form
let ignoreSelector = ''
let selectedPreset = ''
if (monitorData.ignoreRules && monitorData.ignoreRules.length > 0) {
// Use first rule for now as form supports single selector
const rule = monitorData.ignoreRules[0]
if (rule.type === 'css') {
ignoreSelector = rule.value
selectedPreset = 'custom'
// Check if matches preset
const preset = IGNORE_PRESETS.find(p => p.value === rule.value)
if (preset) selectedPreset = preset.value
}
}
setNewMonitor({
url: monitorData.url,
name: monitorData.name,
frequency: monitorData.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitorData.keywordRules as any[]
})
setShowTemplates(false)
setShowAddForm(true)
}
const handleCheckNow = async (id: string) => {
// Prevent multiple simultaneous checks
if (checkingId !== null) return
setCheckingId(id)
try {
const result = await monitorAPI.check(id)
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
} else {
toast.info('No changes detected')
}
refetch()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(err.response?.data?.message || 'Failed to check monitor')
} finally {
setCheckingId(null)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this monitor?')) return
try {
await monitorAPI.delete(id)
toast.success('Monitor deleted')
refetch()
} catch (err) {
console.error('Failed to delete monitor:', err)
toast.error('Failed to delete monitor')
}
}
const monitors = data || []
const filteredMonitors = useMemo(() => {
if (filterStatus === 'all') return monitors
return monitors.filter((m: any) => m.status === filterStatus)
}, [monitors, filterStatus])
// Calculate stats
const stats = useMemo(() => {
const total = monitors.length
const active = monitors.filter((m: any) => m.status === 'active').length
const errors = monitors.filter((m: any) => m.status === 'error').length
const avgUptime = total > 0 ? ((active / total) * 100).toFixed(1) : '0'
return { total, active, errors, avgUptime }
}, [monitors])
if (isLoading) {
return (
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading monitors...</p>
</div>
</div>
</DashboardLayout>
)
}
return (
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
{/* Stats Overview */}
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>}
label="Total Monitors"
value={stats.total}
color="blue"
/>
<StatCard
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
label="Active"
value={stats.active}
subtext="Running smoothly"
color="green"
/>
<StatCard
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>}
label="Errors"
value={stats.errors}
subtext="Needs attention"
color="red"
/>
<StatCard
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>}
label="Avg Uptime"
value={`${stats.avgUptime}%`}
subtext="Last 30 days"
color="amber"
/>
</div>
{/* Template Selection Modal */}
{
showTemplates && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<Card className="max-h-[85vh] w-full max-w-4xl overflow-y-auto">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Choose a Template</CardTitle>
<CardDescription>Start with a pre-configured monitor setup</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)}></Button>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{monitorTemplates.map(template => (
<button
key={template.id}
onClick={() => handleSelectTemplate(template)}
className="flex flex-col items-start gap-2 rounded-lg border p-4 text-left shadow-sm transition-all hover:border-primary hover:bg-primary/5 hover:shadow-md"
>
<span className="text-2xl">{template.icon}</span>
<div>
<h3 className="font-semibold">{template.name}</h3>
<p className="text-xs text-muted-foreground">{template.description}</p>
</div>
</button>
))}
</div>
</CardContent>
</Card>
</div>
)
}
{/* Visual Selector Modal */}
{
showVisualSelector && (
<VisualSelector
url={newMonitor.url}
onSelect={(selector) => {
setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' })
setShowVisualSelector(false)
}}
onClose={() => setShowVisualSelector(false)}
/>
)
}
{/* Actions Bar */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Filter Tabs */}
<div className="flex rounded-lg border bg-muted/30 p-1">
{(['all', 'active', 'error'] as const).map((status) => (
<button
key={status}
onClick={() => setFilterStatus(status)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${filterStatus === status
? 'bg-white text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{status === 'all' ? 'All' : status === 'active' ? 'Active' : 'Errors'}
</button>
))}
</div>
{/* View Toggle */}
<div className="flex rounded-lg border bg-muted/30 p-1">
<button
onClick={() => setViewMode('grid')}
className={`rounded-md p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
title="Grid view"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`rounded-md p-1.5 transition-colors ${viewMode === 'list' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
title="List view"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setShowTemplates(true)}>
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
Templates
</Button>
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Monitor
</Button>
</div>
</div>
{/* Add/Edit Monitor Form */}
{
showAddForm && (
<Card className="mb-6 animate-fade-in border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
<CardHeader>
<CardTitle>{editingId ? 'Edit Monitor' : 'Add New Monitor'}</CardTitle>
<CardDescription>
{editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="URL"
type="url"
value={newMonitor.url}
onChange={(e) => setNewMonitor({ ...newMonitor, url: e.target.value })}
placeholder="https://example.com"
required
/>
<Input
label="Name (optional)"
type="text"
value={newMonitor.name}
onChange={(e) => setNewMonitor({ ...newMonitor, name: e.target.value })}
placeholder="My Monitor"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Select
label="Check Frequency"
value={newMonitor.frequency}
onChange={(e) => setNewMonitor({ ...newMonitor, frequency: parseInt(e.target.value) })}
options={FREQUENCY_OPTIONS.map(opt => ({
...opt,
disabled: opt.value < minFrequency,
label: opt.value < minFrequency ? `${opt.label} (Pro)` : opt.label
}))}
/>
<Select
label="Ignore Content"
value={newMonitor.selectedPreset}
onChange={(e) => {
const preset = e.target.value
if (preset === 'custom') {
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: '' })
} else {
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: preset })
}
}}
options={IGNORE_PRESETS.map(p => ({ value: p.value, label: p.label }))}
hint="Ignore dynamic content like timestamps"
/>
</div>
{newMonitor.selectedPreset === 'custom' && (
<div className="space-y-2">
<Input
label="Custom CSS Selector"
type="text"
value={newMonitor.ignoreSelector}
onChange={(e) => setNewMonitor({ ...newMonitor, ignoreSelector: e.target.value })}
placeholder="e.g. .ad-banner, #timestamp"
hint="Elements matching this selector will be ignored"
/>
{newMonitor.url && (
<Button
type="button"
size="sm"
variant="outline"
className="w-full"
onClick={() => setShowVisualSelector(true)}
>
🎯 Use Visual Selector
</Button>
)}
</div>
)}
{/* Keyword Alerts Section */}
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-sm">Keyword Alerts</h4>
<p className="text-xs text-muted-foreground">Get notified when specific keywords appear or disappear</p>
</div>
{canUseKeywords && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setNewMonitor({
...newMonitor,
keywordRules: [...newMonitor.keywordRules, { keyword: '', type: 'appears', caseSensitive: false }]
})
}}
>
+ Add Keyword
</Button>
)}
</div>
{!canUseKeywords ? (
<div className="flex flex-col items-center justify-center p-4 text-center">
<div className="mb-2 rounded-full bg-muted p-2">
<svg className="h-6 w-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<p className="font-semibold text-sm">Pro Feature</p>
<p className="text-xs text-muted-foreground">Upgrade to Pro to track specific keywords and content changes.</p>
</div>
) : (
<>
{newMonitor.keywordRules.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click "Add Keyword" to create one.</p>
) : (
<div className="space-y-2">
{newMonitor.keywordRules.map((rule, index) => (
<div key={index} className="grid gap-2 rounded-md border bg-card p-3 sm:grid-cols-12">
<div className="sm:col-span-4">
<Input
label=""
type="text"
value={rule.keyword}
onChange={(e) => {
const updated = [...newMonitor.keywordRules]
updated[index].keyword = e.target.value
setNewMonitor({ ...newMonitor, keywordRules: updated })
}}
placeholder="e.g. hiring, sold out"
/>
</div>
<div className="sm:col-span-3">
<Select
label=""
value={rule.type}
onChange={(e) => {
const updated = [...newMonitor.keywordRules]
updated[index].type = e.target.value as any
setNewMonitor({ ...newMonitor, keywordRules: updated })
}}
options={[
{ value: 'appears', label: 'Appears' },
{ value: 'disappears', label: 'Disappears' },
{ value: 'count', label: 'Count changes' }
]}
/>
</div>
{rule.type === 'count' && (
<div className="sm:col-span-2">
<Input
label=""
type="number"
value={rule.threshold || 1}
onChange={(e) => {
const updated = [...newMonitor.keywordRules]
updated[index].threshold = parseInt(e.target.value)
setNewMonitor({ ...newMonitor, keywordRules: updated })
}}
placeholder="Threshold"
/>
</div>
)}
<div className="flex items-center gap-2 sm:col-span-2">
<label className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
checked={rule.caseSensitive || false}
onChange={(e) => {
const updated = [...newMonitor.keywordRules]
updated[index].caseSensitive = e.target.checked
setNewMonitor({ ...newMonitor, keywordRules: updated })
}}
className="rounded border-gray-300"
/>
Case
</label>
</div>
<div className="flex items-center justify-end sm:col-span-1">
<button
type="button"
onClick={() => {
const updated = newMonitor.keywordRules.filter((_, i) => i !== index)
setNewMonitor({ ...newMonitor, keywordRules: updated })
}}
className="rounded p-1 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">
{editingId ? 'Save Changes' : 'Create Monitor'}
</Button>
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card >
)
}
{/* Monitors Grid/List */}
{
filteredMonitors.length === 0 ? (
<Card className="text-center">
<CardContent className="py-12">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<h3 className="mb-2 text-lg font-semibold">
{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}
</h3>
<p className="mb-6 text-muted-foreground">
{filterStatus === 'all'
? 'Start monitoring your first website to get notified of changes'
: 'Try changing the filter to see other monitors'}
</p>
{filterStatus === 'all' && (
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
Create Your First Monitor
</Button>
)}
</CardContent>
</Card>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
{filteredMonitors.map((monitor: any) => (
<Card
key={monitor.id}
hover
className="group animate-fade-in overflow-hidden"
>
<div className={`h-1.5 ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-300'}`} />
<CardContent className="p-5">
{/* Monitor Info */}
<div className="mb-4 flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
<Badge
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
className="flex-shrink-0"
>
{monitor.status}
</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">{monitor.url}</p>
</div>
</div>
{/* Stats Row */}
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
<div>
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
<p className="text-muted-foreground">Frequency</p>
</div>
<div>
{monitor.last_changed_at ? (
<>
<p className="font-semibold text-foreground">
{new Date(monitor.last_changed_at).toLocaleDateString()}
</p>
<p className="text-muted-foreground">Last Change</p>
</>
) : (
<>
<p className="font-semibold text-foreground">-</p>
<p className="text-muted-foreground">Last Change</p>
</>
)}
</div>
</div>
{/* Last Checked */}
{monitor.last_checked_at ? (
<p className="mb-4 text-xs text-muted-foreground">
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
</p>
) : (
<p className="mb-4 text-xs text-muted-foreground">
Not checked yet
</p>
)}
{/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
"{monitor.recentSnapshots[0].summary}"
</p>
)}
{/* Sparkline & Importance */}
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
<div className="mb-4 flex items-end justify-between gap-2">
<div className="flex-1">
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Response Time</p>
<Sparkline
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
height={30}
width={100}
/>
</div>
<div className="flex flex-col items-end">
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Importance</p>
<Badge variant="outline" className={`${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-700'
}`}>
{monitor.recentSnapshots[0].importanceScore || 0}/100
</Badge>
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleCheckNow(monitor.id)}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/monitors/${monitor.id}`)}
title="View History"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(monitor)}
title="Edit Monitor"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(monitor.id)}
title="Delete Monitor"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</CardContent>
</Card>
)
)}
</div>
) : (
/* List View */
<div className="space-y-3">
{filteredMonitors.map((monitor: any) => {
return (
<Card key={monitor.id} hover className="animate-fade-in">
<CardContent className="p-4">
<div className="flex items-center gap-4">
{/* Status Indicator */}
<div className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center ${monitor.status === 'active' ? 'bg-green-100' : monitor.status === 'error' ? 'bg-red-100' : 'bg-gray-100'
}`}>
<div className={`h-3 w-3 rounded-full ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-400'
}`} />
</div>
{/* Monitor Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
<Badge
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
>
{monitor.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground truncate">{monitor.url}</p>
</div>
{/* Stats */}
<div className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground mr-4">
<div className="text-center">
<p className="font-medium text-foreground">{monitor.frequency}m</p>
<p className="text-xs">Frequency</p>
</div>
<div className="text-center w-24">
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? (
<Badge variant="outline" className={`w-full justify-center ${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-700'
}`}>
{monitor.recentSnapshots[0].importanceScore}/100
</Badge>
) : (
<p className="font-medium text-foreground">-</p>
)}
<p className="text-xs mt-1">Importance</p>
</div>
{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
<div className="w-24">
<Sparkline
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
height={24}
width={96}
/>
<p className="text-xs text-center mt-1">Response Time</p>
</div>
)}
<div className="text-center">
<p className="font-medium text-foreground">
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
</p>
<p className="text-xs">Last Change</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleCheckNow(monitor.id)}
loading={checkingId === monitor.id}
disabled={checkingId !== null}
>
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitor.id}`)}>
History
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(monitor.id)}
>
Delete
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
</DashboardLayout >
)
}

View File

@ -1,26 +1,434 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { isAuthenticated } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { HeroSection, UseCaseShowcase, HowItWorks, Differentiators, SocialProof, FinalCTA } from '@/components/landing/LandingSections'
import { LiveStatsBar } from '@/components/landing/LiveStatsBar'
import { PricingComparison } from '@/components/landing/PricingComparison'
import { SectionDivider } from '@/components/landing/MagneticElements'
import { motion, AnimatePresence } from 'framer-motion'
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
export default function Home() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [isAuth, setIsAuth] = useState(false)
const [openFaq, setOpenFaq] = useState<number | null>(null)
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly')
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [scrollProgress, setScrollProgress] = useState(0)
useEffect(() => {
if (isAuthenticated()) {
router.push('/dashboard')
} else {
router.push('/login')
// Check auth status but DO NOT redirect
const auth = isAuthenticated()
setIsAuth(auth)
setLoading(false)
}, [])
// Scroll progress tracking
useEffect(() => {
const handleScroll = () => {
const totalScroll = document.documentElement.scrollHeight - window.innerHeight
const progress = totalScroll > 0 ? (window.scrollY / totalScroll) * 100 : 0
setScrollProgress(progress)
}
}, [router])
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
</div>
)
}
const faqs = [
{
question: 'What is website monitoring?',
answer: 'Website monitoring is the process of testing and verifying that end-users can interact with a website or web application as expected. It continuously checks your website for changes, downtime, or performance issues.'
},
{
question: 'How fast are the alerts?',
answer: 'Our alerts are sent within seconds of detecting a change. You can configure notifications via email, webhook, Slack, or other integrations.'
},
{
question: 'Can I monitor SSL certificates?',
answer: 'Yes! We automatically monitor SSL certificate expiration and will alert you before your certificate expires.'
},
{
question: 'Do you offer a free trial?',
answer: 'Yes, we offer a free Starter plan that includes 3 monitors with hourly checks. No credit card required.'
}
]
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">Website Monitor</h1>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/20 selection:text-primary">
{/* Header */}
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 transition-all duration-200">
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-2 group">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary transition-transform group-hover:scale-110 shadow-lg shadow-primary/20">
<Monitor className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
</Link>
<nav className="hidden items-center gap-6 md:flex">
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
<Link href="#pricing" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Pricing</Link>
</nav>
</div>
<div className="flex items-center gap-3">
{isAuth ? (
<Link href="/dashboard">
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
Dashboard
</Button>
</Link>
) : (
<Link href="/register">
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
Get Started
</Button>
</Link>
)}
{/* Mobile Menu Button */}
<button
className="md:hidden p-2 text-muted-foreground hover:text-foreground"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
<Menu className="h-6 w-6" />
</button>
</div>
</div>
{/* Mobile Menu */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="md:hidden border-t border-border bg-background px-6 py-4 shadow-lg overflow-hidden"
>
<div className="flex flex-col gap-4">
<Link href="#features" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features</Link>
<Link href="#pricing" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Pricing</Link>
{!isAuth && (
<>
<Link href="/register" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-primary font-bold">Get Started</Link>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</header >
{/* Scroll Progress Indicator */}
<motion.div
className="fixed top-16 left-0 right-0 h-1 bg-[hsl(var(--teal))] z-50 origin-left"
style={{ scaleX: scrollProgress / 100 }}
initial={{ scaleX: 0 }}
/>
{/* Hero Section */}
<HeroSection isAuthenticated={isAuth} />
{/* Live Stats Bar */}
<LiveStatsBar />
{/* Use Case Showcase */}
<UseCaseShowcase />
{/* Section Divider: Use Cases -> How It Works */}
<SectionDivider variant="wave" toColor="section-bg-4" />
{/* How It Works */}
<HowItWorks />
{/* Differentiators */}
<Differentiators />
{/* Section Divider: Differentiators -> Pricing */}
<SectionDivider variant="curve" toColor="section-bg-6" />
{/* Pricing Comparison */}
<PricingComparison />
{/* Social Proof */}
<SocialProof />
{/* Pricing Section */}
< section id="pricing" className="border-t border-border/40 bg-[hsl(var(--section-bg-2))] py-24" >
<div className="mx-auto max-w-7xl px-6">
<div className="mb-16 text-center">
<h2 className="mb-4 text-3xl font-bold sm:text-4xl text-foreground">
Simple pricing, no hidden fees
</h2>
<p className="mb-8 text-lg text-muted-foreground">
Start for free and scale as you grow. Change plans anytime.
</p>
<div className="inline-flex items-center rounded-full bg-background p-1.5 shadow-sm border border-border">
<button
onClick={() => setBillingPeriod('monthly')}
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'monthly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
}`}
>
Monthly
</button>
<button
onClick={() => setBillingPeriod('yearly')}
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'yearly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
}`}
>
Yearly <span className="ml-1 text-[10px] opacity-80">(Save 20%)</span>
</button>
</div>
</div>
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
{/* Starter Plan */}
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-primary/20 transition-all"
>
<h3 className="mb-2 text-xl font-bold text-foreground">Starter</h3>
<p className="text-sm text-muted-foreground mb-6">Perfect for side projects</p>
<div className="mb-8">
<span className="text-5xl font-bold tracking-tight text-foreground">$0</span>
<span className="text-muted-foreground ml-2">/mo</span>
</div>
<ul className="mb-8 space-y-4">
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
3 monitors
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
Hourly checks
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
Email alerts
</li>
</ul>
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
Get Started
</Button>
</motion.div>
{/* Pro Plan */}
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className="relative rounded-3xl border-2 border-primary bg-card p-8 shadow-2xl shadow-primary/10 z-10 scale-105"
>
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-bold text-primary-foreground shadow-lg">
MOST POPULAR
</div>
<h3 className="mb-2 text-xl font-bold text-foreground">Pro</h3>
<p className="text-sm text-muted-foreground mb-6">For serious businesses</p>
<div className="mb-8">
<span className="text-5xl font-bold tracking-tight text-foreground">${billingPeriod === 'monthly' ? '29' : '24'}</span>
<span className="text-muted-foreground ml-2">/mo</span>
</div>
<ul className="mb-8 space-y-4">
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
50 monitors
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
1-minute checks
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
All alert channels (Slack/SMS)
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
<Check className="h-3 w-3" />
</div>
SSL monitoring
</li>
</ul>
<Button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl h-11 shadow-lg shadow-primary/20 font-semibold">
Get Started
</Button>
</motion.div>
{/* Enterprise Plan */}
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-border transition-all"
>
<h3 className="mb-2 text-xl font-bold text-foreground">Enterprise</h3>
<p className="text-sm text-muted-foreground mb-6">Custom solutions</p>
<div className="mb-8">
<span className="text-4xl font-bold tracking-tight text-foreground">Custom</span>
</div>
<ul className="mb-8 space-y-4">
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
<Check className="h-3 w-3" />
</div>
Unlimited monitors
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
<Check className="h-3 w-3" />
</div>
30-second checks
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
<Check className="h-3 w-3" />
</div>
SSO &amp; SAML
</li>
<li className="flex items-center gap-3 text-sm text-foreground">
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
<Check className="h-3 w-3" />
</div>
Dedicated support
</li>
</ul>
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
Contact Sales
</Button>
</motion.div>
</div>
</div>
</section >
{/* FAQ Section */}
< section id="faq" className="border-t border-border/40 py-24 bg-background" >
<div className="mx-auto max-w-3xl px-6">
<h2 className="mb-12 text-center text-3xl font-bold sm:text-4xl text-foreground">
Frequently Asked Questions
</h2>
<div className="space-y-4">
{faqs.map((faq, index) => (
<motion.div
key={index}
className="rounded-2xl border border-border bg-card overflow-hidden"
initial={false}
>
<button
onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="flex w-full items-center justify-between p-6 text-left hover:bg-secondary/30 transition-colors"
>
<span className="font-medium text-foreground">{faq.question}</span>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
/>
</button>
<AnimatePresence>
{openFaq === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="border-t border-border px-6 pb-6 pt-4 text-muted-foreground bg-secondary/5"
>
{faq.answer}
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
</div>
</section >
{/* Final CTA */}
<FinalCTA isAuthenticated={isAuth} />
{/* Footer */}
< footer className="border-t border-border bg-background py-12 text-sm" >
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
<div className="md:col-span-2">
<div className="mb-6 flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
<Monitor className="h-5 w-5 text-primary-foreground" />
</div>
<span className="text-lg font-bold text-foreground">MonitorTool</span>
</div>
<p className="text-muted-foreground max-w-xs mb-6">
The modern platform for uptime monitoring, change detection, and performance tracking.
</p>
<div className="flex gap-4">
{/* Social icons placeholders */}
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
<Globe className="h-4 w-4" />
</div>
</div>
</div>
<div>
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
<ul className="space-y-3 text-muted-foreground">
<li><Link href="#features" className="hover:text-primary transition-colors">Features</Link></li>
<li><Link href="#pricing" className="hover:text-primary transition-colors">Pricing</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 >
)
}

View File

@ -5,6 +5,9 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
export default function RegisterPage() {
const router = useRouter()
@ -49,80 +52,94 @@ export default function RegisterPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
<h2 className="mb-6 text-center text-xl text-gray-600">Create Account</h2>
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{/* Subtle Background Pattern */}
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
{error}
<div className="w-full max-w-md animate-fade-in">
<Card className="shadow-xl border-border/50">
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<svg
className="h-7 w-7 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
)}
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
<CardDescription>
Start monitoring your websites for changes
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
<CardContent className="pt-6">
{error && (
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
<Input
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
hint="At least 8 characters with uppercase, lowercase, and number"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
<p className="mt-1 text-xs text-gray-500">
At least 8 characters with uppercase, lowercase, and number
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
id="confirmPassword"
<Input
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<Button
type="submit"
className="w-full"
size="lg"
loading={loading}
>
{loading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
</CardContent>
<p className="mt-6 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary hover:underline">
Sign in
</Link>
</p>
</div>
<CardFooter className="justify-center border-t pt-6">
<p className="text-sm text-muted-foreground">
Already have an account?{' '}
<Link
href="/login"
className="font-medium text-primary hover:underline"
>
Sign in
</Link>
</p>
</CardFooter>
</Card>
</div>
</div>
)

View File

@ -0,0 +1,192 @@
'use client'
import { useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { authAPI } from '@/lib/api'
export default function ResetPasswordPage() {
const router = useRouter()
const params = useParams()
const token = params.token as string
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Client-side validation
if (password !== confirmPassword) {
toast.error('Passwords do not match')
return
}
if (password.length < 8) {
toast.error('Password must be at least 8 characters')
return
}
if (!/[A-Z]/.test(password)) {
toast.error('Password must contain at least one uppercase letter')
return
}
if (!/[a-z]/.test(password)) {
toast.error('Password must contain at least one lowercase letter')
return
}
if (!/[0-9]/.test(password)) {
toast.error('Password must contain at least one number')
return
}
setIsLoading(true)
try {
await authAPI.resetPassword(token, password)
setSuccess(true)
toast.success('Password reset successfully!')
// Redirect to login after 2 seconds
setTimeout(() => {
router.push('/login')
}, 2000)
} catch (error: any) {
console.error('Reset password error:', error)
const message = error.response?.data?.message || 'Failed to reset password. The link may have expired.'
toast.error(message)
} finally {
setIsLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<div className="flex items-center justify-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<span className="text-2xl font-bold">Website Monitor</span>
</div>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Set New Password</CardTitle>
<CardDescription>
{success
? 'Your password has been reset'
: 'Choose a strong password for your account'}
</CardDescription>
</CardHeader>
<CardContent>
{!success ? (
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="New Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
disabled={isLoading}
hint="At least 8 characters, including uppercase, lowercase, and number"
/>
<Input
label="Confirm Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
disabled={isLoading}
/>
{/* Password Requirements */}
<div className="rounded-lg bg-muted/50 p-3 text-xs">
<p className="mb-2 font-medium">Password must contain:</p>
<ul className="space-y-1">
<li className={password.length >= 8 ? 'text-green-600' : 'text-muted-foreground'}>
{password.length >= 8 ? '✓' : '○'} At least 8 characters
</li>
<li className={/[A-Z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
{/[A-Z]/.test(password) ? '✓' : '○'} One uppercase letter
</li>
<li className={/[a-z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
{/[a-z]/.test(password) ? '✓' : '○'} One lowercase letter
</li>
<li className={/[0-9]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
{/[0-9]/.test(password) ? '✓' : '○'} One number
</li>
<li className={password === confirmPassword && password.length > 0 ? 'text-green-600' : 'text-muted-foreground'}>
{password === confirmPassword && password.length > 0 ? '✓' : '○'} Passwords match
</li>
</ul>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? 'Resetting...' : 'Reset Password'}
</Button>
<div className="text-center text-sm">
<Link
href="/login"
className="text-primary hover:underline"
>
Back to Login
</Link>
</div>
</form>
) : (
<div className="space-y-4">
<div className="rounded-lg bg-green-50 p-4 text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="font-medium text-green-900">Password Reset Successfully!</p>
<p className="mt-1 text-sm text-green-700">
You can now log in with your new password.
</p>
</div>
<p className="text-center text-sm text-muted-foreground">
Redirecting to login page...
</p>
<Button
className="w-full"
onClick={() => router.push('/login')}
>
Go to Login
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,471 @@
'use client'
import { useState } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { settingsAPI } from '@/lib/api'
import { clearAuth } from '@/lib/auth'
import { usePlan } from '@/lib/use-plan'
export default function SettingsPage() {
const router = useRouter()
const [showPasswordForm, setShowPasswordForm] = useState(false)
const [showWebhookForm, setShowWebhookForm] = useState(false)
const [showSlackForm, setShowSlackForm] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const { canUseSlack, canUseWebhook } = usePlan()
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const [webhookUrl, setWebhookUrl] = useState('')
const [slackWebhookUrl, setSlackWebhookUrl] = useState('')
const [deletePassword, setDeletePassword] = useState('')
// Fetch user settings
const { data: settings, isLoading, refetch } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await settingsAPI.get()
setWebhookUrl(response.settings.webhookUrl || '')
setSlackWebhookUrl(response.settings.slackWebhookUrl || '')
return response.settings
},
})
// Change password mutation
const changePasswordMutation = useMutation({
mutationFn: async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
throw new Error('Passwords do not match')
}
if (passwordForm.newPassword.length < 8) {
throw new Error('Password must be at least 8 characters')
}
return settingsAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword)
},
onSuccess: () => {
toast.success('Password changed successfully')
setShowPasswordForm(false)
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
},
onError: (error: any) => {
toast.error(error.response?.data?.message || error.message || 'Failed to change password')
},
})
// Toggle email notifications
const toggleEmailMutation = useMutation({
mutationFn: async (enabled: boolean) => {
return settingsAPI.updateNotifications({ emailEnabled: enabled })
},
onSuccess: () => {
toast.success('Email notifications updated')
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update notifications')
},
})
// Update webhook
const updateWebhookMutation = useMutation({
mutationFn: async () => {
return settingsAPI.updateNotifications({
webhookUrl: webhookUrl || null,
webhookEnabled: !!webhookUrl,
})
},
onSuccess: () => {
toast.success('Webhook settings updated')
setShowWebhookForm(false)
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update webhook')
},
})
// Update Slack
const updateSlackMutation = useMutation({
mutationFn: async () => {
return settingsAPI.updateNotifications({
slackWebhookUrl: slackWebhookUrl || null,
slackEnabled: !!slackWebhookUrl,
})
},
onSuccess: () => {
toast.success('Slack integration updated')
setShowSlackForm(false)
refetch()
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to update Slack')
},
})
// Delete account mutation
const deleteAccountMutation = useMutation({
mutationFn: async () => {
return settingsAPI.deleteAccount(deletePassword)
},
onSuccess: () => {
toast.success('Account deleted successfully')
clearAuth()
router.push('/login')
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Failed to delete account')
},
})
if (isLoading) {
return (
<DashboardLayout title="Settings" description="Manage your account and preferences">
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
</DashboardLayout>
)
}
return (
<DashboardLayout title="Settings" description="Manage your account and preferences">
{/* Account Settings */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Manage your account settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="Email"
type="email"
value={settings?.email || ''}
disabled
/>
<div className="flex items-center gap-2">
<Badge>{settings?.plan || 'free'}</Badge>
<span className="text-sm text-muted-foreground">plan</span>
</div>
{!showPasswordForm ? (
<Button variant="outline" onClick={() => setShowPasswordForm(true)}>
Change Password
</Button>
) : (
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
<Input
label="Current Password"
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
required
/>
<Input
label="New Password"
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
hint="At least 8 characters"
required
/>
<Input
label="Confirm New Password"
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
required
/>
<div className="flex gap-2">
<Button
onClick={() => changePasswordMutation.mutate()}
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? 'Saving...' : 'Save Password'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowPasswordForm(false)
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
}}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
{/* Notifications */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Configure how you receive alerts</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Email Notifications */}
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="font-medium">Email Notifications</p>
<p className="text-sm text-muted-foreground">Receive email alerts when changes are detected</p>
</div>
<Button
variant={settings?.emailEnabled !== false ? 'success' : 'outline'}
size="sm"
onClick={() => toggleEmailMutation.mutate(settings?.emailEnabled === false)}
disabled={toggleEmailMutation.isPending}
>
{settings?.emailEnabled !== false ? 'Enabled' : 'Disabled'}
</Button>
</div>
{/* Slack Integration */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Slack Integration</p>
<p className="text-sm text-muted-foreground">Send alerts to your Slack workspace</p>
{settings?.slackEnabled && (
<p className="mt-1 text-xs text-green-600"> Configured</p>
)}
{!canUseSlack && (
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowSlackForm(!showSlackForm)}
disabled={!canUseSlack}
>
{settings?.slackEnabled ? 'Reconfigure' : 'Configure'}
</Button>
</div>
{showSlackForm && (
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
<Input
label="Slack Webhook URL"
type="url"
value={slackWebhookUrl}
onChange={(e) => setSlackWebhookUrl(e.target.value)}
placeholder="https://hooks.slack.com/services/..."
hint="Get this from your Slack app settings"
/>
<div className="flex gap-2">
<Button
onClick={() => updateSlackMutation.mutate()}
disabled={updateSlackMutation.isPending}
size="sm"
>
{updateSlackMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
variant="outline"
onClick={() => setShowSlackForm(false)}
size="sm"
>
Cancel
</Button>
{settings?.slackEnabled && (
<Button
variant="destructive"
onClick={() => {
setSlackWebhookUrl('')
updateSlackMutation.mutate()
}}
size="sm"
>
Remove
</Button>
)}
</div>
</div>
)}
</div>
{/* Webhook */}
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Webhook</p>
<p className="text-sm text-muted-foreground">Send JSON payloads to your server</p>
{settings?.webhookEnabled && (
<p className="mt-1 text-xs text-green-600"> Configured</p>
)}
{!canUseWebhook && (
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
</div>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowWebhookForm(!showWebhookForm)}
disabled={!canUseWebhook}
>
{settings?.webhookEnabled ? 'Reconfigure' : 'Configure'}
</Button>
</div>
{showWebhookForm && (
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
<Input
label="Webhook URL"
type="url"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://your-server.com/webhook"
hint="We'll POST JSON data to this URL on changes"
/>
<div className="flex gap-2">
<Button
onClick={() => updateWebhookMutation.mutate()}
disabled={updateWebhookMutation.isPending}
size="sm"
>
{updateWebhookMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
variant="outline"
onClick={() => setShowWebhookForm(false)}
size="sm"
>
Cancel
</Button>
{settings?.webhookEnabled && (
<Button
variant="destructive"
onClick={() => {
setWebhookUrl('')
updateWebhookMutation.mutate()
}}
size="sm"
>
Remove
</Button>
)}
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Plan & Billing */}
<Card className="mb-6">
<CardHeader>
<CardTitle>Plan & Billing</CardTitle>
<CardDescription>Manage your subscription</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 p-6">
<div>
<div className="flex items-center gap-2">
<p className="text-lg font-bold capitalize">{settings?.plan || 'Free'} Plan</p>
<Badge>Current</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{settings?.plan === 'free' && '5 monitors, 1hr frequency'}
{settings?.plan === 'pro' && '50 monitors, 5min frequency'}
{settings?.plan === 'business' && '200 monitors, 1min frequency'}
{settings?.plan === 'enterprise' && 'Unlimited monitors, all features'}
</p>
{settings?.plan !== 'free' && (
<p className="mt-2 text-sm text-muted-foreground">
Stripe Customer ID: {settings?.stripeCustomerId || 'N/A'}
</p>
)}
</div>
<Button variant="outline" disabled>
Manage Plan
</Button>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>Irreversible actions</CardDescription>
</CardHeader>
<CardContent>
{!showDeleteConfirm ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete Account</p>
<p className="text-sm text-muted-foreground">Permanently delete your account and all data</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
Delete Account
</Button>
</div>
) : (
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="mb-2">
<p className="font-semibold text-destructive"> This action cannot be undone!</p>
<p className="text-sm text-muted-foreground">
All your monitors, snapshots, and alerts will be permanently deleted.
</p>
</div>
<Input
label="Confirm with your password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password"
/>
<div className="flex gap-2">
<Button
variant="destructive"
onClick={() => deleteAccountMutation.mutate()}
disabled={!deletePassword || deleteAccountMutation.isPending}
>
{deleteAccountMutation.isPending ? 'Deleting...' : 'Yes, Delete My Account'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false)
setDeletePassword('')
}}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</DashboardLayout>
)
}

View File

@ -0,0 +1,150 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { authAPI } from '@/lib/api'
export default function VerifyEmailPage() {
const router = useRouter()
const params = useParams()
const token = params.token as string
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
const [message, setMessage] = useState('')
useEffect(() => {
const verifyEmail = async () => {
try {
const response = await authAPI.verifyEmail(token)
setStatus('success')
setMessage(response.message || 'Email verified successfully!')
toast.success('Email verified successfully!')
// Redirect to dashboard after 3 seconds
setTimeout(() => {
router.push('/dashboard')
}, 3000)
} catch (error: any) {
setStatus('error')
const errorMessage = error.response?.data?.message || 'Failed to verify email. The link may have expired.'
setMessage(errorMessage)
toast.error(errorMessage)
}
}
if (token) {
verifyEmail()
}
}, [token, router])
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<div className="flex items-center justify-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
<span className="text-2xl font-bold">Website Monitor</span>
</div>
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Email Verification</CardTitle>
<CardDescription>
{status === 'verifying' && 'Verifying your email address...'}
{status === 'success' && 'Your email has been verified'}
{status === 'error' && 'Verification failed'}
</CardDescription>
</CardHeader>
<CardContent>
{status === 'verifying' && (
<div className="flex flex-col items-center py-8">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
<p className="mt-4 text-sm text-muted-foreground">Please wait...</p>
</div>
)}
{status === 'success' && (
<div className="space-y-4">
<div className="rounded-lg bg-green-50 p-4 text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="font-medium text-green-900">Email Verified!</p>
<p className="mt-1 text-sm text-green-700">{message}</p>
</div>
<p className="text-center text-sm text-muted-foreground">
Redirecting to dashboard...
</p>
<Button
className="w-full"
onClick={() => router.push('/dashboard')}
>
Go to Dashboard
</Button>
</div>
)}
{status === 'error' && (
<div className="space-y-4">
<div className="rounded-lg bg-red-50 p-4 text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="font-medium text-red-900">Verification Failed</p>
<p className="mt-1 text-sm text-red-700">{message}</p>
</div>
<div className="text-sm text-muted-foreground">
<p className="mb-2">Possible reasons:</p>
<ul className="ml-4 list-disc space-y-1">
<li>The verification link has expired (24 hours)</li>
<li>The link was already used</li>
<li>The link is invalid</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="w-full"
onClick={() => router.push('/register')}
>
Register Again
</Button>
<div className="text-center text-sm">
<Link
href="/login"
className="text-primary hover:underline"
>
Back to Login
</Link>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,138 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { Bell, ArrowDown } from 'lucide-react'
export function CompetitorDemoVisual() {
const [phase, setPhase] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setPhase(p => (p + 1) % 2)
}, 3000)
return () => clearInterval(interval)
}, [])
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--primary))]/5 rounded-xl p-4 overflow-hidden">
{/* Browser Header */}
<div className="mb-3 flex items-center gap-2 px-2 py-1.5 rounded-md bg-secondary/50 border border-border">
<div className="flex gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
<div className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
<div className="w-1.5 h-1.5 rounded-full bg-green-400" />
</div>
<div className="text-[9px] text-muted-foreground font-mono">
competitor.com/pricing
</div>
</div>
{/* Pricing Table */}
<div className="space-y-3">
<h4 className="text-xs font-bold text-foreground">Professional Plan</h4>
{/* Price Card */}
<motion.div
className="p-4 rounded-xl border-2 bg-white relative overflow-hidden"
animate={{
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
boxShadow: phase === 1
? '0 0 20px hsl(var(--teal) / 0.3)'
: '0 1px 3px rgba(0,0,0,0.1)'
}}
transition={{ duration: 0.5 }}
>
{/* Shine effect on change */}
{phase === 1 && (
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '200%' }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/50 to-transparent"
style={{ transform: 'skewX(-20deg)' }}
/>
)}
<div className="relative z-10 space-y-2">
{/* Old Price */}
<motion.div
animate={{
opacity: phase === 1 ? 0.4 : 1,
scale: phase === 1 ? 0.95 : 1
}}
transition={{ duration: 0.3 }}
>
<div className="flex items-baseline gap-2">
<motion.span
className="text-3xl font-bold"
animate={{
textDecoration: phase === 1 ? 'line-through' : 'none',
color: phase === 1 ? 'hsl(var(--muted-foreground))' : 'hsl(var(--foreground))'
}}
>
$99
</motion.span>
<span className="text-sm text-muted-foreground">/month</span>
</div>
</motion.div>
{/* New Price with animated arrow */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, x: -10, scale: 0.9 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center gap-2"
>
<ArrowDown className="h-4 w-4 text-[hsl(var(--teal))]" />
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-[hsl(var(--teal))]">
$79
</span>
<span className="text-sm text-muted-foreground">/month</span>
</div>
</motion.div>
)}
{/* Savings Badge */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, scale: 0.8, rotate: -5 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ delay: 0.4, type: 'spring' }}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
>
<span className="text-[9px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
Save $240/year
</span>
</motion.div>
)}
</div>
</motion.div>
{/* Alert Notification */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.6 }}
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<div className="relative flex-shrink-0">
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
<motion.span
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 1, repeat: Infinity }}
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
/>
</div>
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
Alert sent to your team
</span>
</motion.div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,915 @@
'use client'
import { motion, type Variants } from 'framer-motion'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import {
Check, ArrowRight, Shield, Search, FileCheck, TrendingUp,
Target, Filter, Bell, Eye, Slack, Webhook, History,
Zap, Lock, ChevronRight, Star
} from 'lucide-react'
import { useState, useEffect } from 'react'
import { SEODemoVisual } from './SEODemoVisual'
import { CompetitorDemoVisual } from './CompetitorDemoVisual'
import { PolicyDemoVisual } from './PolicyDemoVisual'
import { WaitlistForm } from './WaitlistForm'
import { MagneticButton, SectionDivider } from './MagneticElements'
// Animation Variants
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 30, filter: 'blur(4px)' },
visible: (i: number = 0) => ({
opacity: 1,
y: 0,
filter: 'blur(0px)',
transition: {
delay: i * 0.15,
duration: 0.7,
ease: [0.22, 1, 0.36, 1]
}
})
}
const scaleIn: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] }
}
}
// ============================================
// 1. HERO SECTION - "Track competitor changes without the noise"
// ============================================
export function HeroSection({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<section className="relative overflow-hidden pt-32 pb-24 lg:pt-40 lg:pb-32 bg-[hsl(var(--section-bg-1))]">
{/* Background Elements */}
<div className="absolute inset-0 grain-texture" />
<div className="absolute right-0 top-20 -z-10 h-[600px] w-[600px] rounded-full bg-[hsl(var(--primary))] opacity-8 blur-[120px]" />
<div className="absolute left-0 bottom-0 -z-10 h-[400px] w-[400px] rounded-full bg-[hsl(var(--teal))] opacity-8 blur-[100px]" />
<div className="mx-auto max-w-7xl px-6 relative z-10">
<div className="grid lg:grid-cols-[60%_40%] gap-16 items-center">
{/* Left: Content */}
<motion.div
initial="hidden"
animate="visible"
className="flex flex-col gap-8"
>
{/* Overline */}
<motion.div variants={fadeInUp} custom={0}>
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))]">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--teal))] opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-[hsl(var(--teal))]"></span>
</span>
For SEO & Growth Teams
</div>
</motion.div>
{/* Hero Headline */}
<motion.h1
variants={fadeInUp}
custom={1}
className="text-5xl lg:text-7xl font-display font-bold leading-[1.08] tracking-tight text-foreground"
>
Track competitor changes{' '}
<span className="text-[hsl(var(--primary))]">without the noise.</span>
</motion.h1>
{/* Subheadline */}
<motion.p
variants={fadeInUp}
custom={2}
className="text-xl lg:text-2xl text-muted-foreground font-body leading-relaxed max-w-2xl"
>
Less noise. More signal. Proof included.
</motion.p>
{/* Feature Bullets */}
<motion.div
variants={fadeInUp}
custom={3}
className="grid md:grid-cols-2 gap-4 max-w-2xl"
>
{[
'Auto-filter cookie banners & timestamps',
'Keyword alerts when it matters',
'Slack/Webhook integration',
'Audit-proof history & snapshots'
].map((feature, i) => (
<div key={i} className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-[hsl(var(--teal))]/20">
<Check className="h-3 w-3 text-[hsl(var(--teal))]" strokeWidth={3} />
</div>
<span className="text-foreground text-sm font-medium leading-tight">{feature}</span>
</div>
))}
</motion.div>
{/* CTAs */}
<motion.div
variants={fadeInUp}
custom={4}
className="flex flex-wrap gap-4"
>
<MagneticButton strength={0.2}>
<Link href={isAuthenticated ? "/dashboard" : "/register"}>
<Button
size="lg"
className="h-14 rounded-full bg-[hsl(var(--primary))] px-8 text-white hover:bg-[hsl(var(--primary))]/90 shadow-2xl shadow-[hsl(var(--primary))]/25 transition-all hover:scale-105 hover:-translate-y-0.5 font-semibold text-base group"
>
{isAuthenticated ? 'Go to Dashboard' : 'Get Started Free'}
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</MagneticButton>
</motion.div>
{/* Trust Signals */}
<motion.div
variants={fadeInUp}
custom={5}
className="flex flex-wrap items-center gap-6 text-sm text-muted-foreground"
>
<div className="flex items-center gap-2">
<Lock className="h-4 w-4" />
<span>No credit card</span>
</div>
<span></span>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
<span>No spam, ever</span>
</div>
<span></span>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current" />
<span>Early access bonus</span>
</div>
</motion.div>
</motion.div>
{/* Right: Animated Visual - Noise → Signal */}
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.9, delay: 0.3 }}
className="relative"
>
<NoiseToSignalVisual />
</motion.div>
</div>
</div>
</section>
)
}
// Noise → Signal Animation Component - Enhanced
function NoiseToSignalVisual() {
const [phase, setPhase] = useState(0)
const [isPaused, setIsPaused] = useState(false)
const [particles, setParticles] = useState<{ id: number; x: number; y: number }[]>([])
useEffect(() => {
if (isPaused) return
const interval = setInterval(() => {
setPhase(p => {
const nextPhase = (p + 1) % 4
// Trigger particles when transitioning from phase 0 to 1
if (p === 0 && nextPhase === 1) {
triggerParticles()
}
return nextPhase
})
}, 2500)
return () => clearInterval(interval)
}, [isPaused])
const triggerParticles = () => {
const newParticles = Array.from({ length: 8 }, (_, i) => ({
id: Date.now() + i,
x: Math.random() * 100,
y: Math.random() * 100
}))
setParticles(newParticles)
setTimeout(() => setParticles([]), 1000)
}
return (
<motion.div
className="relative aspect-[4/3] rounded-3xl border-2 border-border bg-card/50 backdrop-blur-sm shadow-2xl overflow-hidden cursor-pointer group"
style={{ perspective: '1000px' }}
whileHover={{ rotateY: 2, rotateX: -2, scale: 1.02 }}
transition={{ duration: 0.3 }}
onHoverStart={() => setIsPaused(true)}
onHoverEnd={() => setIsPaused(false)}
>
{/* Pulsing Glow Border */}
{phase >= 1 && (
<motion.div
className="absolute inset-0 rounded-3xl"
animate={{
boxShadow: [
'0 0 0px hsl(var(--teal))',
'0 0 20px hsl(var(--teal) / 0.5)',
'0 0 0px hsl(var(--teal))'
]
}}
transition={{ duration: 2, repeat: Infinity }}
/>
)}
{/* Particles */}
{particles.map(particle => (
<motion.div
key={particle.id}
className="absolute w-1 h-1 rounded-full bg-[hsl(var(--teal))]"
initial={{ x: `${particle.x}%`, y: `${particle.y}%`, opacity: 1, scale: 1 }}
animate={{
y: `${particle.y - 20}%`,
opacity: 0,
scale: 0
}}
transition={{ duration: 0.8 }}
/>
))}
{/* Mock Browser Header */}
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3">
<div className="flex gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-red-400" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
<div className="h-2.5 w-2.5 rounded-full bg-green-400" />
</div>
<div className="flex-1 mx-4 px-3 py-1 rounded-md bg-background/50 text-xs text-muted-foreground font-mono text-center">
competitor-site.com/pricing
</div>
{isPaused && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="text-[10px] text-muted-foreground font-medium"
>
PAUSED
</motion.div>
)}
</div>
{/* Content Area */}
<div className="p-8 space-y-4 relative">
{/* Noise Counter */}
<motion.div
className="absolute top-4 left-4 px-3 py-1 rounded-full bg-background/80 backdrop-blur-sm border border-border text-xs font-mono font-semibold"
animate={{
opacity: phase === 0 ? 1 : 0.5,
scale: phase === 0 ? 1 : 0.95
}}
>
Noise: {phase === 0 ? '67%' : '0%'}
</motion.div>
{/* Phase 0: Noisy Page */}
<motion.div
animate={{
opacity: phase === 0 ? 1 : 0,
scale: phase === 0 ? 1 : 0.98,
filter: phase === 0 ? 'blur(0px)' : 'blur(8px)'
}}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="space-y-3"
>
{/* Cookie Banner - with strikethrough */}
<motion.div
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
animate={{
x: phase >= 1 ? -10 : 0,
opacity: phase >= 1 ? 0.3 : 1
}}
transition={{ duration: 0.4 }}
>
<span className="text-xs text-muted-foreground">🍪 Cookie Banner</span>
<span className="text-xs text-red-500 font-semibold">
NOISE
</span>
{/* Strikethrough animation */}
{phase >= 1 && (
<motion.div
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.3 }}
/>
)}
</motion.div>
{/* Enterprise Plan Card */}
<div className="p-4 rounded-lg bg-background border border-border">
<p className="text-sm font-semibold text-foreground mb-2">Enterprise Plan</p>
<p className="text-2xl font-bold text-[hsl(var(--burgundy))]">$99/mo</p>
</div>
{/* Timestamp - with strikethrough */}
<motion.div
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
animate={{
x: phase >= 1 ? -10 : 0,
opacity: phase >= 1 ? 0.3 : 1
}}
transition={{ duration: 0.4, delay: 0.1 }}
>
<span className="text-xs text-muted-foreground"> Last updated: 10:23 AM</span>
<span className="text-xs text-red-500 font-semibold">
NOISE
</span>
{/* Strikethrough animation */}
{phase >= 1 && (
<motion.div
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
/>
)}
</motion.div>
</motion.div>
{/* Phase 1-3: Filtered + Highlighted Signal */}
{phase >= 1 && (
<motion.div
initial={{ opacity: 0, scale: 0.85, rotateX: -15 }}
animate={{
opacity: 1,
scale: 1,
rotateX: 0
}}
transition={{
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
scale: { type: 'spring', stiffness: 300, damping: 20 }
}}
className="absolute inset-0 flex items-center justify-center p-8"
>
<motion.div
className="w-full p-6 rounded-2xl bg-white border-2 border-[hsl(var(--teal))] shadow-2xl relative overflow-hidden"
animate={{
boxShadow: [
'0 20px 60px hsl(var(--teal) / 0.2)',
'0 20px 80px hsl(var(--teal) / 0.3)',
'0 20px 60px hsl(var(--teal) / 0.2)'
]
}}
transition={{ duration: 2, repeat: Infinity }}
>
{/* Animated corner accent */}
<motion.div
className="absolute top-0 right-0 w-20 h-20 bg-[hsl(var(--teal))]/10 rounded-bl-full"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
<div className="relative z-10">
<div className="flex items-center justify-between mb-2">
<motion.span
className="text-xs font-bold uppercase tracking-wider text-[hsl(var(--teal))]"
animate={{ opacity: [1, 0.7, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
>
SIGNAL DETECTED
</motion.span>
<div className="flex items-center gap-1.5 text-xs font-medium text-[hsl(var(--teal))]">
<Filter className="h-3 w-3" />
Filtered
</div>
</div>
<p className="text-sm font-semibold text-muted-foreground mb-3">Enterprise Plan</p>
<div className="flex items-baseline gap-3">
<p className="text-3xl font-bold text-foreground">$99/mo</p>
<motion.p
initial={{ opacity: 0, x: -10, scale: 0.9 }}
animate={{
opacity: phase >= 2 ? 1 : 0,
x: phase >= 2 ? 0 : -10,
scale: phase >= 2 ? 1 : 0.9
}}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="text-lg text-[hsl(var(--burgundy))] font-bold flex items-center gap-1"
>
<span></span>
<motion.span
animate={{ scale: phase === 2 ? [1, 1.1, 1] : 1 }}
transition={{ duration: 0.5 }}
>
$79/mo
</motion.span>
</motion.p>
</div>
{/* Alert badge */}
{phase >= 3 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
>
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
<span className="text-[10px] font-bold text-[hsl(var(--burgundy))] uppercase tracking-wider">
Alert Sent
</span>
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
{/* Phase Indicator */}
<div className="absolute bottom-4 right-4 flex gap-1.5">
{[0, 1, 2, 3].map(i => (
<motion.div
key={i}
animate={{
width: phase === i ? 24 : 6,
backgroundColor: phase === i ? 'hsl(var(--teal))' : 'hsl(var(--border))'
}}
transition={{ duration: 0.3 }}
className="h-1.5 rounded-full"
/>
))}
</div>
</div>
</motion.div>
)
}
// ============================================
// 2. USE CASE SHOWCASE - SEO, Competitor, Policy
// ============================================
export function UseCaseShowcase() {
const useCases = [
{
icon: <Search className="h-7 w-7" />,
title: 'SEO Monitoring',
problem: 'Your rankings drop before you know why.',
example: 'Track when competitors update meta descriptions or add new content sections that outrank you.',
color: 'teal',
gradient: 'from-[hsl(var(--teal))]/10 to-transparent',
demoComponent: <SEODemoVisual />
},
{
icon: <TrendingUp className="h-7 w-7" />,
title: 'Competitor Intelligence',
problem: 'Competitor launches slip past your radar.',
example: 'Monitor pricing pages, product launches, and promotional campaigns in real-time.',
color: 'primary',
gradient: 'from-[hsl(var(--primary))]/10 to-transparent',
demoComponent: <CompetitorDemoVisual />
},
{
icon: <FileCheck className="h-7 w-7" />,
title: 'Policy & Compliance',
problem: 'Regulatory updates appear without warning.',
example: 'Track policy changes, terms updates, and legal text modifications with audit-proof history.',
color: 'burgundy',
gradient: 'from-[hsl(var(--burgundy))]/10 to-transparent',
demoComponent: <PolicyDemoVisual />
}
]
return (
<section className="py-32 bg-[hsl(var(--section-bg-3))] relative overflow-hidden">
{/* Background Decor - Enhanced Grid */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border))_1px,transparent_1px)] bg-[size:4rem_4rem] opacity-30 [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
<div className="mx-auto max-w-7xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
className="text-center mb-20"
>
<motion.div variants={fadeInUp} className="inline-flex items-center gap-2 rounded-full bg-secondary border border-border px-4 py-1.5 text-sm font-medium text-foreground mb-6">
<Eye className="h-4 w-4" />
Who This Is For
</motion.div>
<motion.h2 variants={fadeInUp} custom={1} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
Built for teams who need results,{' '}
<span className="text-muted-foreground">not demos.</span>
</motion.h2>
</motion.div>
{/* Use Case Cards - Diagonal Cascade */}
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
{useCases.map((useCase, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 40, rotateX: 10 }}
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.15, duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ y: -12, scale: 1.02, transition: { duration: 0.3 } }}
className="group relative glass-card rounded-3xl shadow-xl hover:shadow-2xl transition-all overflow-hidden"
>
{/* Gradient Background */}
<div className={`absolute inset-0 rounded-3xl bg-gradient-to-br ${useCase.gradient} opacity-0 group-hover:opacity-100 transition-opacity`} />
<div className="relative z-10 p-8 space-y-6">
{/* Icon */}
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
transition={{ duration: 0.2 }}
className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[hsl(var(--${useCase.color}))]/10 text-[hsl(var(--${useCase.color}))] border border-[hsl(var(--${useCase.color}))]/20`}
>
{useCase.icon}
</motion.div>
{/* Title */}
<h3 className="text-2xl font-display font-bold text-foreground">
{useCase.title}
</h3>
{/* Problem Statement */}
<p className="text-sm font-semibold text-muted-foreground">
{useCase.problem}
</p>
{/* Animated Demo Visual */}
<div className="!mt-6 rounded-xl overflow-hidden border border-border/50 shadow-inner">
{useCase.demoComponent}
</div>
{/* Example Scenario */}
<div className="pt-4 border-t border-border">
<p className="text-xs uppercase tracking-wider font-bold text-muted-foreground mb-2">
Example:
</p>
<p className="text-sm text-foreground leading-relaxed">
{useCase.example}
</p>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
)
}
// ============================================
// 3. HOW IT WORKS - 4 Stage Flow
// ============================================
export function HowItWorks() {
const stages = [
{ icon: <Target className="h-6 w-6" />, title: 'Set URL', desc: 'Add the page you want to monitor' },
{ icon: <Zap className="h-6 w-6" />, title: 'Check regularly', desc: 'Automated checks at your chosen frequency' },
{ icon: <Filter className="h-6 w-6" />, title: 'Remove noise', desc: 'AI filters out irrelevant changes' },
{ icon: <Bell className="h-6 w-6" />, title: 'Get alerted', desc: 'Receive notifications that matter' }
]
return (
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-4))] to-[hsl(var(--section-bg-5))] relative overflow-hidden">
{/* Subtle Diagonal Stripe Decoration */}
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, hsl(var(--primary)), hsl(var(--primary)) 2px, transparent 2px, transparent 40px)' }} />
<div className="mx-auto max-w-7xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="text-center mb-20"
>
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
How it works
</motion.h2>
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
Four simple steps to never miss an important change again.
</motion.p>
</motion.div>
{/* Horizontal Flow */}
<div className="relative">
{/* Connecting Line */}
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-border to-transparent -translate-y-1/2 hidden lg:block" />
<div className="grid lg:grid-cols-4 gap-8 lg:gap-4">
{stages.map((stage, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.5 }}
className="relative flex flex-col items-center text-center group"
>
{/* Large Number Background */}
<div className="absolute -top-4 left-1/2 -translate-x-1/2 text-8xl font-display font-bold text-border/20 pointer-events-none">
{String(i + 1).padStart(2, '0')}
</div>
{/* Circle Container */}
<div className="relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-full border-2 border-border bg-card shadow-lg group-hover:shadow-2xl group-hover:border-[hsl(var(--primary))] group-hover:bg-[hsl(var(--primary))]/5 transition-all">
<div className="text-[hsl(var(--primary))]">
{stage.icon}
</div>
</div>
{/* Text */}
<h3 className="text-lg font-bold text-foreground mb-2">
{stage.title}
</h3>
<p className="text-sm text-muted-foreground max-w-[200px]">
{stage.desc}
</p>
{/* Arrow (not on last) */}
{i < stages.length - 1 && (
<div className="hidden lg:block absolute top-10 -right-4 text-border">
<ChevronRight className="h-6 w-6" />
</div>
)}
</motion.div>
))}
</div>
</div>
</div>
</section>
)
}
// ============================================
// 4. DIFFERENTIATORS - Why We're Better
// ============================================
export function Differentiators() {
const features = [
{ feature: 'Noise Filtering', others: 'Basic', us: 'AI-powered + custom rules', icon: <Filter className="h-5 w-5" /> },
{ feature: 'Keyword Alerts', others: 'Limited', us: 'Regex + thresholds', icon: <Search className="h-5 w-5" /> },
{ feature: 'Integrations', others: 'Email only', us: 'Slack, Webhooks, Teams', icon: <Slack className="h-5 w-5" /> },
{ feature: 'History & Proof', others: '7-30 days', us: 'Unlimited snapshots', icon: <History className="h-5 w-5" /> },
{ feature: 'Setup Time', others: '15+ min', us: '2 minutes', icon: <Zap className="h-5 w-5" /> },
{ feature: 'Pricing', others: '$29-99/mo', us: 'Fair pay-per-use', icon: <Shield className="h-5 w-5" /> }
]
return (
<section className="py-32 bg-[hsl(var(--section-bg-5))] relative overflow-hidden">
{/* Radial Gradient Overlay */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,hsl(var(--teal))_0%,transparent_50%)] opacity-5" />
<div className="mx-auto max-w-6xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="text-center mb-20"
>
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
Why we're{' '}
<span className="text-[hsl(var(--teal))]">different</span>
</motion.h2>
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
Not all monitoring tools are created equal. Here's what sets us apart.
</motion.p>
</motion.div>
{/* Feature Cards Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.05, duration: 0.4 }}
className="group relative glass-card rounded-2xl p-6 hover:border-[hsl(var(--teal))]/30 hover:shadow-xl transition-all hover:-translate-y-1"
>
{/* Icon */}
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))] mb-4 group-hover:scale-110 transition-transform">
{item.icon}
</div>
{/* Feature Name */}
<h3 className="text-lg font-bold text-foreground mb-4">
{item.feature}
</h3>
{/* Comparison */}
<div className="space-y-3">
<div className="flex items-start gap-2">
<span className="text-xs uppercase tracking-wider font-bold text-muted-foreground flex-shrink-0 mt-0.5">Others:</span>
<span className="text-sm text-muted-foreground">{item.others}</span>
</div>
<div className="flex items-start gap-2 p-3 rounded-lg bg-[hsl(var(--teal))]/5 border border-[hsl(var(--teal))]/20">
<Check className="h-4 w-4 text-[hsl(var(--teal))] flex-shrink-0 mt-0.5" strokeWidth={3} />
<span className="text-sm font-semibold text-foreground">{item.us}</span>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
)
}
// ============================================
// 5. SOCIAL PROOF - Testimonials (Prepared for Beta)
// ============================================
export function SocialProof() {
const testimonials = [
{
quote: "The noise filtering alone saves me 2 hours per week. Finally, monitoring that actually works.",
author: "[Beta User]",
role: "SEO Manager",
company: "[Company]",
useCase: "SEO Monitoring"
},
{
quote: "We catch competitor price changes within minutes. Game-changer for our pricing strategy.",
author: "[Beta User]",
role: "Growth Lead",
company: "[Company]",
useCase: "Competitor Intelligence"
},
{
quote: "Audit-proof history saved us during compliance review. Worth every penny.",
author: "[Beta User]",
role: "Compliance Officer",
company: "[Company]",
useCase: "Policy Tracking"
}
]
return (
<section className="py-32 bg-gradient-to-b from-foreground to-[hsl(var(--foreground))]/95 relative overflow-hidden text-white">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
backgroundSize: '60px 60px'
}} />
</div>
<div className="mx-auto max-w-7xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="text-center mb-20"
>
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold mb-6">
Built for teams who need results,{' '}
<span className="text-[hsl(var(--primary))]">not demos.</span>
</motion.h2>
</motion.div>
{/* Testimonial Cards - Minimal & Uniform */}
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
whileHover={{ y: -4 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.5 }}
className="relative group"
>
{/* Subtle gradient border glow */}
<div className="absolute -inset-0.5 bg-gradient-to-br from-[hsl(var(--primary))] to-[hsl(var(--teal))] rounded-3xl blur opacity-15 group-hover:opacity-25 transition-opacity duration-300" />
{/* Main Card - fixed height for uniformity */}
<div className="relative h-full flex flex-col rounded-3xl bg-white/10 backdrop-blur-sm border border-white/20 p-8 group-hover:bg-white/12 transition-colors duration-300">
{/* Large Quote Mark */}
<div className="text-5xl font-display text-[hsl(var(--primary))]/30 leading-none mb-3">
"
</div>
{/* Quote - flex-grow ensures cards align */}
<p className="font-body text-base leading-relaxed mb-6 text-white/90 font-medium italic flex-grow">
{testimonial.quote}
</p>
{/* Attribution - always at bottom */}
<div className="flex items-start justify-between mt-auto">
<div>
<p className="font-bold text-white text-sm">{testimonial.author}</p>
<p className="text-xs text-white/70">{testimonial.role} at {testimonial.company}</p>
</div>
<div className="px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 border border-[hsl(var(--teal))]/30 text-[10px] font-bold uppercase tracking-wider text-[hsl(var(--teal))]">
{testimonial.useCase}
</div>
</div>
</div>
</motion.div>
))}
</div>
{/* Note */}
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-center mt-12 text-sm text-white/60"
>
Join our waitlist to become a beta tester and get featured here.
</motion.p>
</div>
</section>
)
}
// ============================================
// 6. FINAL CTA - Get Started
// ============================================
export function FinalCTA({ isAuthenticated }: { isAuthenticated: boolean }) {
return (
<section className="relative overflow-hidden py-32">
{/* Animated Gradient Mesh Background - More dramatic */}
<div className="absolute inset-0 bg-gradient-to-br from-[hsl(var(--primary))]/30 via-[hsl(var(--burgundy))]/20 to-[hsl(var(--teal))]/30 opacity-70" />
<div className="absolute inset-0 grain-texture" />
{/* Animated Orbs - Enhanced */}
<motion.div
animate={{
scale: [1, 1.3, 1],
opacity: [0.4, 0.6, 0.4],
rotate: [0, 180, 360]
}}
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
className="absolute top-1/4 -left-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary))] blur-[140px]"
/>
<motion.div
animate={{
scale: [1, 1.2, 1],
opacity: [0.4, 0.5, 0.4],
rotate: [360, 180, 0]
}}
transition={{ duration: 15, repeat: Infinity, ease: "linear", delay: 2 }}
className="absolute bottom-1/4 -right-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--teal))] blur-[140px]"
/>
<div className="mx-auto max-w-4xl px-6 text-center relative z-10">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="space-y-8"
>
{/* Headline */}
<motion.h2 variants={fadeInUp} className="text-5xl lg:text-6xl font-display font-bold leading-tight text-foreground">
Stop missing the changes{' '}
<span className="text-[hsl(var(--primary))]">that matter.</span>
</motion.h2>
{/* Subheadline */}
<motion.p variants={fadeInUp} custom={1} className="text-xl lg:text-2xl text-muted-foreground max-w-2xl mx-auto">
Join the waitlist and be first to experience monitoring that actually works.
</motion.p>
{/* Waitlist Form - replaces button */}
<motion.div variants={fadeInUp} custom={2} className="pt-4">
{isAuthenticated ? (
<MagneticButton strength={0.15}>
<Link href="/dashboard">
<Button
size="lg"
className="h-16 rounded-full bg-[hsl(var(--burgundy))] px-12 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 font-bold text-lg group"
>
Go to Dashboard
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</MagneticButton>
) : (
<WaitlistForm />
)}
</motion.div>
{/* Social Proof Indicator */}
<motion.div
variants={fadeInUp}
custom={3}
className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground"
>
<div className="flex items-center gap-2">
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-2 h-2 rounded-full bg-green-500"
/>
<span className="font-semibold text-foreground">500+ joined this week</span>
</div>
<span></span>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
</div>
</motion.div>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,179 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { Activity, TrendingUp, Zap, Shield } from 'lucide-react'
function AnimatedNumber({ value, suffix = '' }: { value: number; suffix?: string }) {
const [displayValue, setDisplayValue] = useState(0)
useEffect(() => {
const duration = 2000 // 2 seconds
const steps = 60
const increment = value / steps
const stepDuration = duration / steps
let currentStep = 0
const interval = setInterval(() => {
currentStep++
if (currentStep <= steps) {
setDisplayValue(Math.floor(increment * currentStep))
} else {
setDisplayValue(value)
clearInterval(interval)
}
}, stepDuration)
return () => clearInterval(interval)
}, [value])
return (
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
{displayValue.toLocaleString()}{suffix}
</span>
)
}
function FluctuatingNumber({ base, variance }: { base: number; variance: number }) {
const [value, setValue] = useState(base)
useEffect(() => {
const interval = setInterval(() => {
const fluctuation = (Math.random() - 0.5) * variance
setValue(base + fluctuation)
}, 1500)
return () => clearInterval(interval)
}, [base, variance])
return (
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
{Math.round(value)}ms
</span>
)
}
export function LiveStatsBar() {
const stats = [
{
icon: <Activity className="h-5 w-5" />,
label: 'Checks performed today',
value: 2847,
type: 'counter' as const
},
{
icon: <TrendingUp className="h-5 w-5" />,
label: 'Changes detected this hour',
value: 127,
type: 'counter' as const
},
{
icon: <Shield className="h-5 w-5" />,
label: 'Uptime',
value: '99.9%',
type: 'static' as const
},
{
icon: <Zap className="h-5 w-5" />,
label: 'Avg response time',
value: '< ',
type: 'fluctuating' as const,
base: 42,
variance: 10
}
]
return (
<section className="border-y border-border bg-gradient-to-r from-foreground/95 via-foreground to-foreground/95 py-8 overflow-hidden">
<div className="mx-auto max-w-7xl px-6">
{/* Desktop: Grid */}
<div className="hidden lg:grid lg:grid-cols-4 gap-8">
{stats.map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.5 }}
className="flex flex-col items-center text-center gap-3"
>
{/* Icon */}
<motion.div
className="flex items-center justify-center w-12 h-12 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]"
whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ duration: 0.2 }}
>
{stat.icon}
</motion.div>
{/* Value */}
<div>
{stat.type === 'counter' && typeof stat.value === 'number' && (
<AnimatedNumber value={stat.value} />
)}
{stat.type === 'static' && (
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
{stat.value}
</span>
)}
{stat.type === 'fluctuating' && stat.base && stat.variance && (
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
</span>
)}
</div>
{/* Label */}
<p className="text-xs font-medium text-white/90 uppercase tracking-wider">
{stat.label}
</p>
</motion.div>
))}
</div>
{/* Mobile: Horizontal Scroll */}
<div className="lg:hidden overflow-x-auto scrollbar-thin pb-2">
<div className="flex gap-8 min-w-max px-4">
{stats.map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1, duration: 0.5 }}
className="flex flex-col items-center text-center gap-3 min-w-[160px]"
>
{/* Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]">
{stat.icon}
</div>
{/* Value */}
<div>
{stat.type === 'counter' && typeof stat.value === 'number' && (
<AnimatedNumber value={stat.value} />
)}
{stat.type === 'static' && (
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
{stat.value}
</span>
)}
{stat.type === 'fluctuating' && stat.base && stat.variance && (
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
</span>
)}
</div>
{/* Label */}
<p className="text-[10px] font-medium text-white/90 uppercase tracking-wider">
{stat.label}
</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,120 @@
'use client'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
import { useRef, ReactNode } from 'react'
interface MagneticButtonProps {
children: ReactNode
className?: string
onClick?: () => void
strength?: number
}
export function MagneticButton({
children,
className = '',
onClick,
strength = 0.3
}: MagneticButtonProps) {
const ref = useRef<HTMLDivElement>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
const springConfig = { stiffness: 300, damping: 20 }
const springX = useSpring(x, springConfig)
const springY = useSpring(y, springConfig)
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return
const rect = ref.current.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const deltaX = (e.clientX - centerX) * strength
const deltaY = (e.clientY - centerY) * strength
x.set(deltaX)
y.set(deltaY)
}
const handleMouseLeave = () => {
x.set(0)
y.set(0)
}
return (
<motion.div
ref={ref}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={onClick}
style={{ x: springX, y: springY }}
className={`inline-block ${className}`}
>
{children}
</motion.div>
)
}
interface SectionDividerProps {
variant?: 'wave' | 'diagonal' | 'curve'
fromColor?: string
toColor?: string
flip?: boolean
}
export function SectionDivider({
variant = 'wave',
fromColor = 'section-bg-3',
toColor = 'section-bg-4',
flip = false
}: SectionDividerProps) {
if (variant === 'wave') {
return (
<div className={`w-full h-20 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
<svg
viewBox="0 0 1200 120"
preserveAspectRatio="none"
className="w-full h-full"
>
<path
d="M0,0 Q300,80 600,40 T1200,0 L1200,120 L0,120 Z"
fill={`hsl(var(--${toColor}))`}
/>
</svg>
</div>
)
}
if (variant === 'diagonal') {
return (
<div
className={`w-full h-16 ${flip ? '-skew-y-2' : 'skew-y-2'}`}
style={{
background: `linear-gradient(to bottom right, hsl(var(--${fromColor})), hsl(var(--${toColor})))`
}}
/>
)
}
if (variant === 'curve') {
return (
<div className={`w-full h-24 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
<svg
viewBox="0 0 1200 120"
preserveAspectRatio="none"
className="w-full h-full"
>
<path
d="M0,60 Q300,120 600,60 T1200,60 L1200,120 L0,120 Z"
fill={`hsl(var(--${toColor}))`}
/>
</svg>
</div>
)
}
return null
}

View File

@ -0,0 +1,148 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { FileCheck, Check } from 'lucide-react'
export function PolicyDemoVisual() {
const [phase, setPhase] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setPhase(p => (p + 1) % 2)
}, 3000)
return () => clearInterval(interval)
}, [])
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--burgundy))]/5 rounded-xl p-4 overflow-hidden">
{/* Document Header */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<FileCheck className="h-4 w-4 text-[hsl(var(--burgundy))]" />
<span className="text-xs font-bold text-foreground">Terms of Service</span>
</div>
<motion.div
className="px-2 py-0.5 rounded-full border text-[9px] font-bold"
animate={{
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.1)' : 'transparent',
color: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--muted-foreground))'
}}
transition={{ duration: 0.5 }}
>
{phase === 0 ? 'v2.1' : 'v2.2'}
</motion.div>
</div>
{/* Document Content */}
<motion.div
className="space-y-2 p-3 rounded-lg border-2 bg-white overflow-hidden"
animate={{
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
boxShadow: phase === 1
? '0 0 20px hsl(var(--teal) / 0.3)'
: '0 1px 3px rgba(0,0,0,0.1)'
}}
transition={{ duration: 0.5 }}
>
{/* Section 4.2 */}
<div className="space-y-1.5">
<div className="text-[10px] font-bold text-[hsl(var(--primary))]">
Section 4.2 - Data Retention
</div>
{/* Text Lines */}
<div className="space-y-1 text-[9px] text-muted-foreground leading-relaxed">
<p>We will retain your personal data for</p>
{/* Changing text */}
<motion.div
className="relative rounded"
layout
>
<motion.p
animate={{
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.15)' : 'transparent',
paddingLeft: phase === 1 ? '4px' : '0px',
paddingRight: phase === 1 ? '4px' : '0px',
color: phase === 1 ? 'hsl(var(--burgundy))' : 'hsl(var(--muted-foreground))',
fontWeight: phase === 1 ? 600 : 400
}}
transition={{ duration: 0.4 }}
className="relative inline-block rounded"
>
{phase === 0 ? (
'as long as necessary to fulfill purposes'
) : (
<motion.span
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
a minimum of 90 days after account deletion
</motion.span>
)}
</motion.p>
{/* Change highlight indicator */}
{phase === 1 && (
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.4 }}
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
/>
)}
</motion.div>
<p>outlined in our Privacy Policy.</p>
</div>
</div>
{/* Diff Stats */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
transition={{ delay: 0.3 }}
className="pt-2 border-t border-border flex items-center justify-between"
>
<div className="flex items-center gap-3 text-[8px] text-muted-foreground">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded bg-green-500/20 border border-green-500" />
+18 words
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
-7 words
</span>
</div>
</motion.div>
)}
</motion.div>
{/* Audit Trail Badge */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ delay: 0.5 }}
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
>
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--teal))] text-white">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
<div className="flex-1">
<div className="text-[9px] font-bold text-[hsl(var(--teal))]">
Audit trail saved
</div>
<div className="text-[8px] text-muted-foreground">
Snapshot archived for compliance
</div>
</div>
</motion.div>
)}
</div>
)
}

View File

@ -0,0 +1,255 @@
'use client'
import { motion } from 'framer-motion'
import { useState } from 'react'
import { TrendingDown, DollarSign } from 'lucide-react'
export function PricingComparison() {
const [monitorCount, setMonitorCount] = useState(50)
// Pricing calculation logic
const calculatePricing = (monitors: number) => {
// Competitors: tiered pricing
let competitorMin, competitorMax
if (monitors <= 10) {
competitorMin = 29
competitorMax = 49
} else if (monitors <= 50) {
competitorMin = 79
competitorMax = 129
} else if (monitors <= 100) {
competitorMin = 129
competitorMax = 199
} else {
competitorMin = 199
competitorMax = 299
}
// Our pricing: simpler, fairer
let ourPrice
if (monitors <= 10) {
ourPrice = 19
} else if (monitors <= 50) {
ourPrice = 49
} else if (monitors <= 100) {
ourPrice = 89
} else {
ourPrice = 149
}
const competitorAvg = (competitorMin + competitorMax) / 2
const savings = competitorAvg - ourPrice
const savingsPercent = Math.round((savings / competitorAvg) * 100)
return {
competitorMin,
competitorMax,
competitorAvg,
ourPrice,
savings,
savingsPercent
}
}
const pricing = calculatePricing(monitorCount)
return (
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-6))] to-[hsl(var(--section-bg-3))] relative overflow-hidden">
{/* Background Pattern - Enhanced Dot Grid */}
<div className="absolute inset-0 opacity-8">
<div className="absolute inset-0" style={{
backgroundImage: `radial-gradient(circle, hsl(var(--teal)) 1.5px, transparent 1.5px)`,
backgroundSize: '30px 30px'
}} />
</div>
<div className="mx-auto max-w-5xl px-6 relative z-10">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="text-center mb-16"
>
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))] mb-6">
<DollarSign className="h-4 w-4" />
Fair Pricing
</div>
<h2 className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
See how much you{' '}
<span className="text-[hsl(var(--teal))]">save</span>
</h2>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Compare our transparent pricing with typical competitors. No hidden fees, no surprises.
</p>
</motion.div>
{/* Interactive Comparison Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="rounded-3xl border-2 border-border bg-card p-8 lg:p-12 shadow-2xl"
>
{/* Monitor Count Slider */}
<div className="mb-12">
<div className="flex items-center justify-between mb-4">
<label className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
Number of Monitors
</label>
<motion.div
key={monitorCount}
initial={{ scale: 1.2 }}
animate={{ scale: 1 }}
className="text-4xl font-bold text-foreground font-mono"
>
{monitorCount}
</motion.div>
</div>
{/* Slider */}
<div className="relative">
<input
type="range"
min="5"
max="200"
step="5"
value={monitorCount}
onChange={(e) => setMonitorCount(Number(e.target.value))}
className="w-full h-3 bg-secondary rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[hsl(var(--teal))]
[&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:cursor-grab
[&::-webkit-slider-thumb]:active:cursor-grabbing [&::-webkit-slider-thumb]:hover:scale-110
[&::-webkit-slider-thumb]:transition-transform
[&::-moz-range-thumb]:w-6 [&::-moz-range-thumb]:h-6
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[hsl(var(--teal))]
[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-lg
[&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing"
/>
{/* Tick marks - positioned by percentage based on slider range (5-200) */}
<div className="relative mt-2 h-4">
<span className="absolute text-xs text-muted-foreground" style={{ left: '0%', transform: 'translateX(0)' }}>5</span>
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((50 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>50</span>
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((100 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>100</span>
<span className="absolute text-xs text-muted-foreground" style={{ left: '100%', transform: 'translateX(-100%)' }}>200</span>
</div>
</div>
</div>
{/* Price Comparison Bars */}
<div className="grid lg:grid-cols-2 gap-8 mb-8">
{/* Competitors */}
<motion.div
layout
className="space-y-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
Typical Competitors
</span>
</div>
{/* Bar */}
<motion.div
className="relative h-24 rounded-2xl bg-gradient-to-r from-red-500/10 to-red-500/20 border-2 border-red-500/30 overflow-hidden"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-red-500/20 to-red-500/40"
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: 'left' }}
/>
<div className="relative h-full flex items-center justify-center">
<div className="text-center">
<motion.div
key={`comp-${pricing.competitorMin}-${pricing.competitorMax}`}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-4xl font-bold text-red-700 font-mono"
>
${pricing.competitorMin}-{pricing.competitorMax}
</motion.div>
<div className="text-xs font-medium text-red-600">per month</div>
</div>
</div>
</motion.div>
</motion.div>
{/* Us */}
<motion.div
layout
className="space-y-4"
>
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
Our Pricing
</span>
</div>
{/* Bar */}
<motion.div
className="relative h-24 rounded-2xl bg-gradient-to-r from-[hsl(var(--teal))]/10 to-[hsl(var(--teal))]/20 border-2 border-[hsl(var(--teal))]/30 overflow-hidden"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-[hsl(var(--teal))]/20 to-[hsl(var(--teal))]/40"
initial={{ scaleX: 0 }}
animate={{ scaleX: pricing.ourPrice / pricing.competitorMax }}
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
style={{ transformOrigin: 'left' }}
/>
<div className="relative h-full flex items-center justify-center">
<div className="text-center">
<motion.div
key={`our-${pricing.ourPrice}`}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-5xl font-bold text-[hsl(var(--teal))] font-mono"
>
${pricing.ourPrice}
</motion.div>
<div className="text-xs font-medium text-[hsl(var(--teal))]">per month</div>
</div>
</div>
</motion.div>
</motion.div>
</div>
{/* Savings Badge */}
<motion.div
key={`savings-${pricing.savings}`}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="flex items-center justify-center gap-4 p-6 rounded-2xl bg-gradient-to-r from-[hsl(var(--primary))]/10 via-[hsl(var(--teal))]/10 to-[hsl(var(--burgundy))]/10 border-2 border-[hsl(var(--teal))]/30"
>
<TrendingDown className="h-8 w-8 text-[hsl(var(--teal))]" />
<div className="text-center">
<div className="text-sm font-medium text-muted-foreground">You save</div>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-foreground">
${Math.round(pricing.savings)}
</span>
<span className="text-xl text-muted-foreground">/month</span>
<span className="ml-2 px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 text-sm font-bold text-[hsl(var(--teal))]">
{pricing.savingsPercent}% off
</span>
</div>
</div>
</motion.div>
{/* Fine Print */}
<p className="mt-6 text-center text-xs text-muted-foreground">
* Based on average pricing from Visualping, Distill.io, and similar competitors as of Jan 2026
</p>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,124 @@
'use client'
import { motion } from 'framer-motion'
import { useState, useEffect } from 'react'
import { TrendingDown, TrendingUp } from 'lucide-react'
export function SEODemoVisual() {
const [phase, setPhase] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setPhase(p => (p + 1) % 2)
}, 3000)
return () => clearInterval(interval)
}, [])
const oldMeta = "Best enterprise software for teams of all sizes. Try free for 30 days."
const newMeta = "Best enterprise software for teams of all sizes. Try free for 30 days. Now with AI-powered analytics and real-time collaboration."
return (
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--teal))]/5 rounded-xl p-4 overflow-hidden">
{/* SERP Result */}
<div className="space-y-4">
{/* Ranking Indicator */}
<div className="flex items-center justify-between">
<div className="text-xs font-mono text-muted-foreground">
google.com/search
</div>
<motion.div
className="flex items-center gap-1 px-2 py-1 rounded-full bg-background border border-border"
animate={{
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--burgundy))',
backgroundColor: phase === 0 ? 'hsl(var(--background))' : 'hsl(var(--burgundy) / 0.1)'
}}
transition={{ duration: 0.5 }}
>
<span className="text-xs font-bold">Ranking:</span>
<motion.span
key={phase}
initial={{ y: -10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
className="text-xs font-bold"
>
#{phase === 0 ? '3' : '5'}
</motion.span>
{phase === 1 && (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
>
<TrendingDown className="h-3 w-3 text-[hsl(var(--burgundy))]" />
</motion.div>
)}
</motion.div>
</div>
{/* SERP Snippet */}
<motion.div
className="space-y-2 p-3 rounded-lg bg-white border-2"
animate={{
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--teal))',
boxShadow: phase === 0
? '0 1px 3px rgba(0,0,0,0.1)'
: '0 0 20px hsl(var(--teal) / 0.3)'
}}
transition={{ duration: 0.5 }}
>
{/* URL */}
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-primary" />
<span className="text-[10px] text-muted-foreground font-mono">
competitor.com/product
</span>
</div>
{/* Title */}
<h4 className="text-sm font-bold text-[hsl(var(--primary))] line-clamp-1">
Best Enterprise Software Solution 2026
</h4>
{/* Meta Description with change highlighting */}
<motion.p
className="text-[11px] text-muted-foreground leading-relaxed relative"
layout
>
<motion.span
animate={{
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.2)' : 'transparent'
}}
transition={{ duration: 0.5 }}
className="inline-block rounded px-0.5"
>
{phase === 0 ? oldMeta : newMeta}
</motion.span>
{/* Change indicator */}
{phase === 1 && (
<motion.span
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
>
NEW
</motion.span>
)}
</motion.p>
</motion.div>
{/* Alert Badge */}
{phase === 1 && (
<motion.div
initial={{ opacity: 0, y: 5, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="flex items-center justify-center gap-2 text-[10px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider"
>
<span className="w-1.5 h-1.5 rounded-full bg-[hsl(var(--teal))] animate-pulse" />
Meta Description Changed
</motion.div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,256 @@
'use client'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect } from 'react'
import { Check, ArrowRight, Loader2, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function WaitlistForm() {
const [email, setEmail] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [error, setError] = useState('')
const [queuePosition, setQueuePosition] = useState(0)
const [confetti, setConfetti] = useState<Array<{ id: number; x: number; y: number; rotation: number; color: string }>>([])
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
const triggerConfetti = () => {
const colors = ['hsl(var(--primary))', 'hsl(var(--teal))', 'hsl(var(--burgundy))', '#fbbf24', '#f97316']
const particles = Array.from({ length: 50 }, (_, i) => ({
id: Date.now() + i,
x: 50 + (Math.random() - 0.5) * 40, // Center around 50%
y: 50,
rotation: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)]
}))
setConfetti(particles)
setTimeout(() => setConfetti([]), 3000)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!email) {
setError('Please enter your email')
return
}
if (!validateEmail(email)) {
setError('Please enter a valid email')
return
}
setIsSubmitting(true)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500))
// Generate a random queue position
const position = Math.floor(Math.random() * 500) + 400
setQueuePosition(position)
setIsSubmitting(false)
setIsSuccess(true)
triggerConfetti()
}
if (isSuccess) {
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="relative max-w-md mx-auto"
>
{/* Confetti */}
{confetti.map(particle => (
<motion.div
key={particle.id}
className="absolute w-2 h-2 rounded-full"
style={{
backgroundColor: particle.color,
left: `${particle.x}%`,
top: `${particle.y}%`
}}
initial={{ opacity: 1, scale: 1 }}
animate={{
y: [-20, window.innerHeight / 4],
x: [(Math.random() - 0.5) * 200],
opacity: [1, 1, 0],
rotate: [particle.rotation, particle.rotation + 720],
scale: [1, 0.5, 0]
}}
transition={{
duration: 2 + Math.random(),
ease: [0.45, 0, 0.55, 1]
}}
/>
))}
{/* Success Card */}
<motion.div
initial={{ y: 20 }}
animate={{ y: 0 }}
className="relative overflow-hidden rounded-3xl border-2 border-[hsl(var(--teal))] bg-white shadow-2xl shadow-[hsl(var(--teal))]/20 p-8 text-center"
>
{/* Animated background accent */}
<motion.div
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[hsl(var(--primary))] via-[hsl(var(--teal))] to-[hsl(var(--burgundy))]"
animate={{
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
}}
transition={{ duration: 3, repeat: Infinity }}
style={{ backgroundSize: '200% 100%' }}
/>
{/* Success Icon */}
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.2 }}
className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[hsl(var(--teal))]/10 border-2 border-[hsl(var(--teal))]"
>
<Check className="h-10 w-10 text-[hsl(var(--teal))]" strokeWidth={3} />
</motion.div>
{/* Success Message */}
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-3 text-3xl font-display font-bold text-foreground"
>
You're on the list!
</motion.h3>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="mb-6 text-muted-foreground"
>
Check your inbox for confirmation
</motion.p>
{/* Queue Position */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5, type: 'spring' }}
className="inline-flex items-center gap-3 rounded-full bg-gradient-to-r from-[hsl(var(--primary))]/10 to-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30 px-6 py-3"
>
<Sparkles className="h-5 w-5 text-[hsl(var(--primary))]" />
<div className="text-left">
<div className="text-xs font-medium text-muted-foreground">
Your position
</div>
<div className="text-2xl font-bold text-foreground">
#{queuePosition}
</div>
</div>
</motion.div>
{/* Bonus Badge */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
>
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
🎉 Early access: 50% off for 6 months
</span>
</motion.div>
</motion.div>
</motion.div>
)
}
return (
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
onSubmit={handleSubmit}
className="max-w-md mx-auto"
>
<div className="flex flex-col sm:flex-row gap-3">
{/* Email Input */}
<motion.div
className="flex-1 relative"
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
transition={{ duration: 0.4 }}
>
<input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
placeholder="Enter your email"
disabled={isSubmitting}
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${
error
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
} disabled:opacity-50 disabled:cursor-not-allowed`}
/>
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
>
{error}
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Submit Button */}
<Button
type="submit"
disabled={isSubmitting || !email}
size="lg"
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Joining...
</>
) : (
<>
Reserve Your Spot
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</Button>
</div>
{/* Trust Signals Below Form */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mt-6 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"
>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
<span>No credit card needed</span>
</div>
<span className="hidden sm:inline"></span>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
<span>No spam, ever</span>
</div>
</motion.div>
</motion.form>
)
}

View File

@ -0,0 +1,49 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
import { Sidebar } from '@/components/layout/sidebar'
interface DashboardLayoutProps {
children: React.ReactNode
title?: string
description?: string
}
export function DashboardLayout({ children, title, description }: DashboardLayoutProps) {
const router = useRouter()
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
}
}, [router])
return (
<div className="min-h-screen bg-background">
<Sidebar />
{/* Main Content Area - responsive margin for sidebar */}
<div className="lg:pl-64">
{/* Header */}
{(title || description) && (
<header className="sticky top-0 z-30 border-b border-border/50 bg-background/80 backdrop-blur-lg">
<div className="px-8 py-6 pl-16 lg:pl-8">
{title && <h1 className="text-2xl font-bold">{title}</h1>}
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
</header>
)}
{/* Page Content - extra top padding on mobile for hamburger button */}
<main className="p-8 pt-4 lg:pt-8">
{children}
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,250 @@
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { settingsAPI } from '@/lib/api'
import { cn } from '@/lib/utils'
import { clearAuth } from '@/lib/auth'
import { Badge } from '@/components/ui/badge'
interface NavItem {
label: string
href: string
icon: React.ReactNode
}
interface SidebarProps {
isOpen?: boolean
onClose?: () => void
}
const navItems: NavItem[] = [
{
label: 'Dashboard',
href: '/dashboard',
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
label: 'Monitors',
href: '/monitors',
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
),
},
{
label: 'Incidents',
href: '/incidents',
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
},
{
label: 'Analytics',
href: '/analytics',
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
label: 'Settings',
href: '/settings',
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
]
export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
const pathname = usePathname()
const router = useRouter()
const [mobileOpen, setMobileOpen] = useState(false)
// Fetch user settings to show current plan
const { data: settingsData } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
try {
const response = await settingsAPI.get()
return response.settings || {}
} catch (e) {
return {}
}
},
})
// Default to stored user plan from localStorage if API fails or is loading
const getStoredPlan = () => {
if (typeof window !== 'undefined') {
try {
const userStr = localStorage.getItem('user');
if (userStr) return JSON.parse(userStr).plan;
} catch { return 'free'; }
}
return 'free';
}
// Capitalize plan name
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
// Determine badge color
const getBadgeVariant = (plan: string) => {
switch (plan?.toLowerCase()) {
case 'pro': return 'default'; // Primary color
case 'business': return 'secondary';
case 'enterprise': return 'destructive'; // Or another prominent color
default: return 'outline';
}
};
// Use controlled state if provided, otherwise use internal state
const sidebarOpen = isOpen !== undefined ? isOpen : mobileOpen
const handleClose = onClose || (() => setMobileOpen(false))
const handleLogout = () => {
clearAuth()
router.push('/login')
}
const isActive = (href: string) => {
if (href === '/dashboard') {
return pathname === '/dashboard'
}
if (href === '/monitors') {
return pathname === '/monitors' || pathname?.startsWith('/monitors/')
}
return pathname === href || pathname?.startsWith(href + '/')
}
const handleNavClick = () => {
// Close mobile sidebar after navigation
if (window.innerWidth < 1024) {
handleClose()
}
}
return (
<>
{/* Mobile Hamburger Button */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border/50 shadow-md lg:hidden"
aria-label="Toggle menu"
>
<svg className="h-6 w-6 text-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={mobileOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
/>
</svg>
</button>
{/* Mobile Overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-300"
onClick={handleClose}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={cn(
"fixed left-0 top-0 z-40 h-screen w-64 border-r border-border/50 bg-card/95 backdrop-blur-sm",
"transition-transform duration-300 ease-in-out",
"lg:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-border/50 px-6">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<svg
className="h-5 w-5 text-primary-foreground"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</div>
<div>
<h1 className="font-bold text-foreground">WebMonitor</h1>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant={getBadgeVariant(planName)} className="px-1.5 py-0 h-5 text-[10px] uppercase">
{planName}
</Badge>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{navItems.map((item) => {
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
onClick={handleNavClick}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
active
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
)}
>
<span className={cn(active && 'text-primary')}>{item.icon}</span>
{item.label}
</Link>
)
})}
</nav>
{/* Footer */}
<div className="border-t border-border/50 p-3">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-secondary hover:text-foreground"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Log out
</button>
</div>
</div>
</aside>
</>
)
}

View File

@ -0,0 +1,46 @@
import React from 'react'
interface SparklineProps {
data: number[]
width?: number
height?: number
color?: string
strokeWidth?: number
}
export function Sparkline({
data,
width = 120,
height = 40,
color = 'currentColor',
strokeWidth = 2
}: SparklineProps) {
if (!data || data.length < 2) {
return null
}
// Normalize data to fit height
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1 // Avoid division by zero
// Calculate points
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * width
const y = height - ((value - min) / range) * height
return `${x},${y}`
}).join(' ')
return (
<svg width={width} height={height} className="overflow-visible">
<polyline
points={points}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}

View File

@ -0,0 +1,40 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default:
"bg-primary/10 text-primary",
secondary:
"bg-secondary text-secondary-foreground",
destructive:
"bg-destructive/10 text-destructive",
success:
"bg-green-100 text-green-700",
warning:
"bg-yellow-100 text-yellow-700",
outline:
"border border-border text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,85 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-md hover:bg-primary/90 hover:shadow-lg",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost:
"hover:bg-secondary hover:text-secondary-foreground",
link:
"text-primary underline-offset-4 hover:underline",
success:
"bg-success text-success-foreground shadow-sm hover:bg-success/90",
warning:
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3 text-xs",
lg: "h-12 rounded-lg px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg
className="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{children}
</button>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { hover?: boolean }
>(({ className, hover = false, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200",
hover && "hover:shadow-md hover:border-primary/30 cursor-pointer",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,47 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, hint, id, ...props }, ref) => {
const inputId = id || React.useId()
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<input
type={type}
id={inputId}
className={cn(
"flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
{...props}
/>
{hint && !error && (
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
)}
{error && (
<p className="mt-1 text-xs text-destructive">{error}</p>
)}
</div>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,54 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string
error?: string
hint?: string
options: { value: string | number; label: string }[]
}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, label, error, hint, id, options, ...props }, ref) => {
const selectId = id || React.useId()
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="mb-1.5 block text-sm font-medium text-foreground"
>
{label}
</label>
)}
<select
id={selectId}
className={cn(
"flex h-10 w-full appearance-none rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
"bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat pr-10",
error && "border-destructive focus-visible:ring-destructive",
className
)}
ref={ref}
{...props}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{hint && !error && (
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
)}
{error && (
<p className="mt-1 text-xs text-destructive">{error}</p>
)}
</div>
)
}
)
Select.displayName = "Select"
export { Select }

View File

@ -0,0 +1,303 @@
'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
interface VisualSelectorProps {
url: string
onSelect: (selector: string) => void
onClose: () => void
}
/**
* Generate an optimal CSS selector for an element
*/
function generateSelector(element: Element): string {
// Try ID first
if (element.id) {
return `#${element.id}`
}
// Try unique class combination
if (element.classList.length > 0) {
const classes = Array.from(element.classList)
const classSelector = '.' + classes.join('.')
if (document.querySelectorAll(classSelector).length === 1) {
return classSelector
}
}
// Build path from parent elements
const path: string[] = []
let current: Element | null = element
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector = `#${current.id}`
path.unshift(selector)
break
}
if (current.classList.length > 0) {
const significantClasses = Array.from(current.classList)
.filter(c => !c.includes('hover') && !c.includes('active') && !c.includes('focus'))
.slice(0, 2)
if (significantClasses.length > 0) {
selector += '.' + significantClasses.join('.')
}
}
// Add nth-child if needed for uniqueness
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter(
c => c.tagName === current!.tagName
)
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
}
path.unshift(selector)
current = current.parentElement
}
return path.join(' > ')
}
export function VisualSelector({ url, onSelect, onClose }: VisualSelectorProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedSelector, setSelectedSelector] = useState('')
const [testResult, setTestResult] = useState<{ count: number; success: boolean } | null>(null)
const [proxyHtml, setProxyHtml] = useState<string | null>(null)
// Fetch page content through proxy
useEffect(() => {
async function fetchProxyContent() {
try {
setLoading(true)
setError(null)
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`)
if (!response.ok) {
throw new Error('Failed to load page')
}
const html = await response.text()
setProxyHtml(html)
setLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load page')
setLoading(false)
}
}
fetchProxyContent()
}, [url])
// Handle clicks within the iframe
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current
if (!iframe?.contentDocument) return
const doc = iframe.contentDocument
// Inject selection styles
const style = doc.createElement('style')
style.textContent = `
.visual-selector-hover {
outline: 2px solid #3b82f6 !important;
outline-offset: 2px;
cursor: crosshair !important;
}
.visual-selector-selected {
outline: 3px solid #22c55e !important;
outline-offset: 2px;
background-color: rgba(34, 197, 94, 0.1) !important;
}
`
doc.head.appendChild(style)
// Add event listeners
const handleMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target && target !== doc.body) {
target.classList.add('visual-selector-hover')
}
}
const handleMouseOut = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target) {
target.classList.remove('visual-selector-hover')
}
}
const handleClick = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const target = e.target as HTMLElement
if (!target || target === doc.body) return
// Remove previous selection
doc.querySelectorAll('.visual-selector-selected').forEach(el => {
el.classList.remove('visual-selector-selected')
})
// Add selection to current element
target.classList.add('visual-selector-selected')
// Generate and set selector
const selector = generateSelector(target)
setSelectedSelector(selector)
// Test the selector
const matches = doc.querySelectorAll(selector)
setTestResult({
count: matches.length,
success: matches.length === 1
})
}
doc.body.addEventListener('mouseover', handleMouseOver)
doc.body.addEventListener('mouseout', handleMouseOut)
doc.body.addEventListener('click', handleClick)
// Cleanup
return () => {
doc.body.removeEventListener('mouseover', handleMouseOver)
doc.body.removeEventListener('mouseout', handleMouseOut)
doc.body.removeEventListener('click', handleClick)
}
}, [])
const handleConfirm = () => {
if (selectedSelector) {
onSelect(selectedSelector)
}
}
const handleTestSelector = () => {
const iframe = iframeRef.current
if (!iframe?.contentDocument || !selectedSelector) return
try {
const matches = iframe.contentDocument.querySelectorAll(selectedSelector)
setTestResult({
count: matches.length,
success: matches.length === 1
})
// Highlight matches
iframe.contentDocument.querySelectorAll('.visual-selector-selected').forEach(el => {
el.classList.remove('visual-selector-selected')
})
matches.forEach(el => {
el.classList.add('visual-selector-selected')
})
} catch {
setTestResult({ count: 0, success: false })
}
}
return (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle>Visual Element Selector</CardTitle>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<p className="text-sm text-muted-foreground">
Click on an element to select it. The CSS selector will be generated automatically.
</p>
</CardHeader>
<CardContent className="flex-1 overflow-hidden flex flex-col gap-4">
{/* URL display */}
<div className="text-sm text-muted-foreground truncate">
Loading: {url}
</div>
{/* Iframe container */}
<div className="flex-1 relative border rounded-lg overflow-hidden bg-white">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Loading page...</p>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
<div className="text-center p-4">
<p className="text-destructive font-medium">Failed to load page</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-xs text-muted-foreground mt-2">
Note: Some sites may block embedding due to security policies.
</p>
</div>
</div>
)}
{proxyHtml && (
<iframe
ref={iframeRef}
srcDoc={proxyHtml}
className="w-full h-full"
sandbox="allow-same-origin"
onLoad={handleIframeLoad}
style={{ minHeight: '400px' }}
/>
)}
</div>
{/* Selector controls */}
<div className="flex-shrink-0 space-y-3">
<div className="flex gap-2">
<Input
value={selectedSelector}
onChange={(e) => setSelectedSelector(e.target.value)}
placeholder="CSS selector will appear here..."
className="flex-1 font-mono text-sm"
/>
<Button variant="outline" onClick={handleTestSelector} disabled={!selectedSelector}>
Test
</Button>
</div>
{testResult && (
<div className={`text-sm ${testResult.success ? 'text-green-600' : 'text-orange-600'}`}>
{testResult.success
? `✓ Selector matches exactly 1 element`
: `⚠ Selector matches ${testResult.count} elements (should be 1)`
}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedSelector}>
Use This Selector
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -42,6 +42,26 @@ export const authAPI = {
const response = await api.post('/auth/login', { email, password });
return response.data;
},
forgotPassword: async (email: string) => {
const response = await api.post('/auth/forgot-password', { email });
return response.data;
},
resetPassword: async (token: string, newPassword: string) => {
const response = await api.post('/auth/reset-password', { token, newPassword });
return response.data;
},
verifyEmail: async (token: string) => {
const response = await api.post('/auth/verify-email', { token });
return response.data;
},
resendVerification: async (email: string) => {
const response = await api.post('/auth/resend-verification', { email });
return response.data;
},
};
// Monitor API
@ -87,4 +107,67 @@ export const monitorAPI = {
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
return response.data;
},
exportAuditTrail: async (id: string, format: 'json' | 'csv' = 'json') => {
const token = localStorage.getItem('token');
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
const url = `${API_URL}/api/monitors/${id}/export?format=${format}`;
// Create a hidden link and trigger download
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error('Export failed');
}
const blob = await response.blob();
const filename = response.headers.get('Content-Disposition')?.split('filename="')[1]?.replace('"', '')
|| `export.${format}`;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
},
};
// Settings API
export const settingsAPI = {
get: async () => {
const response = await api.get('/settings');
return response.data;
},
changePassword: async (currentPassword: string, newPassword: string) => {
const response = await api.post('/settings/change-password', {
currentPassword,
newPassword,
});
return response.data;
},
updateNotifications: async (data: {
emailEnabled?: boolean;
webhookUrl?: string | null;
webhookEnabled?: boolean;
slackWebhookUrl?: string | null;
slackEnabled?: boolean;
}) => {
const response = await api.put('/settings/notifications', data);
return response.data;
},
deleteAccount: async (password: string) => {
const response = await api.delete('/settings/account', {
data: { password },
});
return response.data;
},
};

203
frontend/lib/templates.ts Normal file
View File

@ -0,0 +1,203 @@
// Monitor Templates - Pre-configured monitoring setups for popular sites
export interface MonitorTemplate {
id: string;
name: string;
description: string;
category: 'ecommerce' | 'social' | 'news' | 'dev' | 'business' | 'other';
icon: string;
urlPattern: string; // Regex pattern for URL matching
urlPlaceholder: string; // Example URL with placeholder
selector?: string;
ignoreRules: Array<{
type: 'css' | 'regex' | 'text';
value: string;
description?: string;
}>;
keywordRules?: Array<{
keyword: string;
type: 'appears' | 'disappears' | 'count_increases' | 'count_decreases';
}>;
frequency: number; // in minutes
}
export const monitorTemplates: MonitorTemplate[] = [
// E-Commerce
{
id: 'amazon-price',
name: 'Amazon Product Price',
description: 'Track price changes on Amazon product pages',
category: 'ecommerce',
icon: '🛒',
urlPattern: 'amazon\\.(com|de|co\\.uk|fr|es|it)/.*dp/[A-Z0-9]+',
urlPlaceholder: 'https://amazon.com/dp/PRODUCTID',
selector: '#priceblock_ourprice, .a-price .a-offscreen, #corePrice_feature_div .a-price-whole',
ignoreRules: [
{ type: 'css', value: '#nav, #rhf, #navFooter', description: 'Navigation and footer' },
{ type: 'css', value: '.a-carousel, #recommendations', description: 'Recommendations' },
{ type: 'regex', value: '\\d+ customer reviews?', description: 'Review count changes' }
],
frequency: 60
},
{
id: 'ebay-price',
name: 'eBay Listing Price',
description: 'Monitor eBay listing prices and availability',
category: 'ecommerce',
icon: '🏷️',
urlPattern: 'ebay\\.(com|de|co\\.uk)/itm/',
urlPlaceholder: 'https://www.ebay.com/itm/ITEMID',
selector: '.x-price-primary',
ignoreRules: [
{ type: 'css', value: '#vi-VR, #STORE_INFORMATION', description: 'Store info' }
],
frequency: 30
},
// Developer Tools
{
id: 'github-releases',
name: 'GitHub Releases',
description: 'Get notified when new releases are published',
category: 'dev',
icon: '📦',
urlPattern: 'github\\.com/[\\w-]+/[\\w-]+/releases',
urlPlaceholder: 'https://github.com/owner/repo/releases',
selector: '.release, [data-hpc] .Box-row',
ignoreRules: [
{ type: 'css', value: 'footer, .js-stale-session-flash', description: 'Footer' }
],
keywordRules: [
{ keyword: 'Latest', type: 'appears' }
],
frequency: 360 // 6 hours
},
{
id: 'npm-package',
name: 'NPM Package',
description: 'Track new versions of NPM packages',
category: 'dev',
icon: '📦',
urlPattern: 'npmjs\\.com/package/[\\w@/-]+',
urlPlaceholder: 'https://www.npmjs.com/package/package-name',
selector: '#top h3, .css-1t74l4c',
ignoreRules: [
{ type: 'css', value: 'footer, .downloads', description: 'Footer and download stats' }
],
frequency: 1440 // Daily
},
// News & Content
{
id: 'reddit-thread',
name: 'Reddit Thread',
description: 'Monitor a Reddit thread for new comments',
category: 'social',
icon: '📰',
urlPattern: 'reddit\\.com/r/\\w+/comments/',
urlPlaceholder: 'https://www.reddit.com/r/subreddit/comments/...',
ignoreRules: [
{ type: 'regex', value: '\\d+ points?', description: 'Vote counts' },
{ type: 'regex', value: '\\d+ (minute|hour|day)s? ago', description: 'Timestamps' }
],
frequency: 30
},
{
id: 'hackernews-front',
name: 'Hacker News Front Page',
description: 'Track top stories on Hacker News',
category: 'news',
icon: '📰',
urlPattern: 'news\\.ycombinator\\.com/?$',
urlPlaceholder: 'https://news.ycombinator.com/',
selector: '.titleline',
ignoreRules: [
{ type: 'regex', value: '\\d+ points?', description: 'Points' },
{ type: 'regex', value: '\\d+ comments?', description: 'Comment count' }
],
frequency: 60
},
// Business & Jobs
{
id: 'job-board',
name: 'Job Board',
description: 'Monitor job postings on a company career page',
category: 'business',
icon: '💼',
urlPattern: '.*/(careers?|jobs?)/?$',
urlPlaceholder: 'https://company.com/careers',
ignoreRules: [
{ type: 'css', value: 'footer, nav, header', description: 'Navigation' }
],
keywordRules: [
{ keyword: 'Senior', type: 'appears' },
{ keyword: 'Remote', type: 'appears' }
],
frequency: 360 // 6 hours
},
{
id: 'competitor-pricing',
name: 'Competitor Pricing',
description: 'Track competitor pricing page changes',
category: 'business',
icon: '💰',
urlPattern: '.*/pricing/?$',
urlPlaceholder: 'https://competitor.com/pricing',
selector: '.price, .pricing-card, [class*="price"]',
ignoreRules: [
{ type: 'css', value: 'footer, nav', description: 'Navigation' }
],
frequency: 1440 // Daily
},
// Generic
{
id: 'generic-page',
name: 'Generic Web Page',
description: 'Monitor any web page for changes',
category: 'other',
icon: '🌐',
urlPattern: '.*',
urlPlaceholder: 'https://example.com/page',
ignoreRules: [
{ type: 'css', value: 'script, style, noscript', description: 'Scripts' }
],
frequency: 60
}
];
/**
* Find matching templates for a given URL
*/
export function findMatchingTemplates(url: string): MonitorTemplate[] {
return monitorTemplates.filter(template => {
try {
const regex = new RegExp(template.urlPattern, 'i');
return regex.test(url);
} catch {
return false;
}
});
}
/**
* Get templates by category
*/
export function getTemplatesByCategory(category: MonitorTemplate['category']): MonitorTemplate[] {
return monitorTemplates.filter(t => t.category === category);
}
/**
* Apply a template to create monitor configuration
*/
export function applyTemplate(template: MonitorTemplate, url: string) {
return {
url,
name: `${template.name} Monitor`,
frequency: template.frequency,
elementSelector: template.selector || null,
ignoreRules: template.ignoreRules,
keywordRules: template.keywordRules || [],
};
}

17
frontend/lib/types.ts Normal file
View File

@ -0,0 +1,17 @@
export interface Monitor {
id: string
url: string
name: string
frequency: number
status: 'active' | 'paused' | 'error'
last_checked_at?: string
last_changed_at?: string
consecutive_errors: number
recentSnapshots?: {
id: string
responseTime: number
importanceScore?: number
changed: boolean
createdAt: string
}[]
}

43
frontend/lib/use-plan.ts Normal file
View File

@ -0,0 +1,43 @@
import { getAuth } from './auth'
export type UserPlan = 'free' | 'pro' | 'business' | 'enterprise'
export const PLAN_LIMITS = {
free: {
maxMonitors: 3,
minFrequency: 60, // minutes
features: ['email_alerts', 'basic_noise_filtering'],
},
pro: {
maxMonitors: 20,
minFrequency: 5,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export'],
},
business: {
maxMonitors: 100,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members'],
},
enterprise: {
maxMonitors: Infinity,
minFrequency: 1,
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members', 'custom_integrations', 'sla'],
},
} as const
export function usePlan() {
const auth = getAuth()
const plan = (auth?.user?.plan as UserPlan) || 'free'
const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free
return {
plan,
limits,
canUseSlack: limits.features.includes('slack_integration' as any),
canUseWebhook: limits.features.includes('webhook_integration' as any),
canUseKeywords: limits.features.includes('keyword_alerts' as any),
canUseSmartNoise: limits.features.includes('smart_noise_filtering' as any),
maxMonitors: limits.maxMonitors,
minFrequency: limits.minFrequency,
}
}

6
frontend/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

5
frontend/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -13,11 +13,13 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.6",
"framer-motion": "^12.27.0",
"lucide-react": "^0.303.0",
"next": "14.0.4",
"react": "^18.2.0",
"react-diff-viewer-continued": "^3.3.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.4"
},
@ -2574,6 +2576,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -3044,6 +3047,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.27.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.27.0.tgz",
"integrity": "sha512-gJtqOKEDJH/jrn0PpsWp64gdOjBvGX8hY6TWstxjDot/85daIEtJHl1UsiwHSXiYmJF2QXUoXP6/3gGw5xY2YA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.27.0",
"motion-utils": "^12.24.10",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -4215,6 +4245,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "12.27.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.27.0.tgz",
"integrity": "sha512-oDjl0WoAsWIWKl3GCDxmh7GITrNjmLX+w5+jwk4+pzLu3VnFvsOv2E6+xCXeH72O65xlXsr84/otiOYQKW/nQA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.24.10"
}
},
"node_modules/motion-utils": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -5326,6 +5371,16 @@
"node": ">=8"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@ -5690,24 +5745,6 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",

View File

@ -3,34 +3,36 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"dev": "next dev -p 3021",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-query": "^5.17.9",
"axios": "^1.6.5",
"zod": "^3.22.4",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"class-variance-authority": "^0.7.0",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0",
"date-fns": "^3.0.6",
"react-diff-viewer-continued": "^3.3.1"
"framer-motion": "^12.27.0",
"lucide-react": "^0.303.0",
"next": "14.0.4",
"react": "^18.2.0",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"typescript": "^5.3.3",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.33",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-config-next": "14.0.4"
"eslint-config-next": "14.0.4",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3"
}
}

View File

@ -8,6 +8,9 @@ const config: Config = {
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
@ -26,6 +29,14 @@ const config: Config = {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
@ -48,6 +59,25 @@ const config: Config = {
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
fadeIn: {
from: { opacity: '0', transform: 'translateY(8px)' },
to: { opacity: '1', transform: 'translateY(0)' },
},
slideIn: {
from: { opacity: '0', transform: 'translateX(-16px)' },
to: { opacity: '1', transform: 'translateX(0)' },
},
pulseGlow: {
'0%, 100%': { boxShadow: '0 0 4px rgba(196, 178, 156, 0.4)' },
'50%': { boxShadow: '0 0 12px rgba(196, 178, 156, 0.7)' },
},
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out forwards',
'slide-in': 'slideIn 0.3s ease-out forwards',
'pulse-glow': 'pulseGlow 2s ease-in-out infinite',
},
},
},
plugins: [],

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More