gitea +
This commit is contained in:
parent
4733e1a1cc
commit
fd6e7c44e1
|
|
@ -23,6 +23,7 @@
|
|||
"nodemailer": "^6.9.8",
|
||||
"openai": "^6.16.0",
|
||||
"pg": "^8.11.3",
|
||||
"serpapi": "^2.2.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -762,6 +763,7 @@
|
|||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
|
|
@ -3437,6 +3439,7 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3619,6 +3622,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4018,6 +4022,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -4920,6 +4925,7 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -7570,6 +7576,7 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
|
||||
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.10.0",
|
||||
"pg-pool": "^3.11.0",
|
||||
|
|
@ -8203,6 +8210,12 @@
|
|||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serpapi": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/serpapi/-/serpapi-2.2.1.tgz",
|
||||
"integrity": "sha512-1HXXaIwDmYueFPauAggIkzozMi5P/a4/yRUxB8Z1kad0VQE/7ohf9a6xRQ99aXR252itDChfmJUfBdCA4phCYA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
|
|
@ -8701,6 +8714,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -8991,6 +9005,7 @@
|
|||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"nodemailer": "^6.9.8",
|
||||
"openai": "^6.16.0",
|
||||
"pg": "^8.11.3",
|
||||
"serpapi": "^2.2.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import 'dotenv/config';
|
||||
import { query } from '../src/db';
|
||||
|
||||
async function verifyWaitlistTable() {
|
||||
try {
|
||||
console.log('Verifying waitlist_leads table...');
|
||||
|
||||
const result = await query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'waitlist_leads'
|
||||
);
|
||||
`);
|
||||
|
||||
if (!result.rows[0].exists) {
|
||||
console.log('Table waitlist_leads does not exist. Creating it...');
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS waitlist_leads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
source VARCHAR(50) DEFAULT 'landing_page',
|
||||
referrer TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_email ON waitlist_leads(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_created_at ON waitlist_leads(created_at);
|
||||
`);
|
||||
console.log('Table waitlist_leads created successfully.');
|
||||
} else {
|
||||
console.log('Table waitlist_leads already exists.');
|
||||
|
||||
// Check columns just in case
|
||||
const columns = await query(`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'waitlist_leads';
|
||||
`);
|
||||
console.log('Columns:', columns.rows.map(r => r.column_name).join(', '));
|
||||
}
|
||||
|
||||
console.log('Verification complete.');
|
||||
} catch (error) {
|
||||
console.error('Error verifying waitlist table:', error);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
verifyWaitlistTable();
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
|
||||
import { Pool } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load env vars from .env file in backend root
|
||||
dotenv.config({ path: path.join(__dirname, '../../.env') });
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
async function fixSchema() {
|
||||
console.log('🔧 Fixing schema...');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Add seo_keywords column
|
||||
console.log('Adding seo_keywords column...');
|
||||
await client.query(`
|
||||
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_keywords JSONB;
|
||||
`);
|
||||
|
||||
// Create monitor_rankings table
|
||||
console.log('Creating monitor_rankings table...');
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS monitor_rankings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
keyword VARCHAR(255) NOT NULL,
|
||||
rank INTEGER,
|
||||
url_found TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for monitor_rankings
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_monitor_id ON monitor_rankings(monitor_id);`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_keyword ON monitor_rankings(keyword);`);
|
||||
await client.query(`CREATE INDEX IF NOT EXISTS idx_monitor_rankings_created_at ON monitor_rankings(created_at);`);
|
||||
|
||||
console.log('✅ Schema fixed successfully!');
|
||||
} catch (err) {
|
||||
console.error('❌ Schema fix failed:', err);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
fixSchema();
|
||||
|
|
@ -13,7 +13,7 @@ function toCamelCase<T>(obj: any): T {
|
|||
let value = obj[key];
|
||||
|
||||
// Parse JSON fields that are stored as strings in the database
|
||||
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
|
||||
if ((key === 'ignore_rules' || key === 'keyword_rules' || key === 'seo_keywords') && typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
|
|
@ -164,8 +164,8 @@ export const db = {
|
|||
const result = await query(
|
||||
`INSERT INTO monitors (
|
||||
user_id, url, name, frequency, status, element_selector,
|
||||
ignore_rules, keyword_rules
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
ignore_rules, keyword_rules, seo_keywords, seo_interval
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[
|
||||
data.userId,
|
||||
data.url,
|
||||
|
|
@ -175,6 +175,8 @@ export const db = {
|
|||
data.elementSelector || null,
|
||||
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
|
||||
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
|
||||
data.seoKeywords ? JSON.stringify(data.seoKeywords) : null,
|
||||
data.seoInterval || 'off',
|
||||
]
|
||||
);
|
||||
return toCamelCase<Monitor>(result.rows[0]);
|
||||
|
|
@ -220,9 +222,12 @@ export const db = {
|
|||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
if (key === 'ignoreRules' || key === 'keywordRules') {
|
||||
if (key === 'ignoreRules' || key === 'keywordRules' || key === 'seoKeywords') {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else if (key === 'seoInterval') {
|
||||
fields.push(`seo_interval = $${paramCount}`);
|
||||
values.push(value);
|
||||
} else {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(value);
|
||||
|
|
@ -433,6 +438,47 @@ export const db = {
|
|||
return result.rows.map(row => toCamelCase<any>(row));
|
||||
},
|
||||
},
|
||||
|
||||
rankings: {
|
||||
async create(data: {
|
||||
monitorId: string;
|
||||
keyword: string;
|
||||
rank: number | null;
|
||||
urlFound: string | null;
|
||||
}): Promise<any> {
|
||||
const result = await query(
|
||||
`INSERT INTO monitor_rankings (
|
||||
monitor_id, keyword, rank, url_found
|
||||
) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[data.monitorId, data.keyword, data.rank, data.urlFound]
|
||||
);
|
||||
return toCamelCase<any>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findLatestByMonitorId(monitorId: string, limit = 50): Promise<any[]> {
|
||||
// Gets the latest check per keyword for this monitor
|
||||
// Using DISTINCT ON is efficient in Postgres
|
||||
const result = await query(
|
||||
`SELECT DISTINCT ON (keyword) *
|
||||
FROM monitor_rankings
|
||||
WHERE monitor_id = $1
|
||||
ORDER BY keyword, created_at DESC`,
|
||||
[monitorId]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<any>(row));
|
||||
},
|
||||
|
||||
async findHistoryByMonitorId(monitorId: string, limit = 100): Promise<any[]> {
|
||||
const result = await query(
|
||||
`SELECT * FROM monitor_rankings
|
||||
WHERE monitor_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[monitorId, limit]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<any>(row));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_interval VARCHAR(20) DEFAULT 'off';
|
||||
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS last_seo_check_at TIMESTAMP;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
|
||||
import { Pool } from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
async function runManualMigration() {
|
||||
console.log('🔄 Running manual migration 006...');
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const sqlPath = path.join(__dirname, 'migrations/006_add_seo_scheduling.sql');
|
||||
const sql = fs.readFileSync(sqlPath, 'utf-8');
|
||||
console.log('📝 Executing SQL:', sql);
|
||||
await client.query(sql);
|
||||
console.log('✅ Migration 006 applied successfully!');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Migration 006 failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
runManualMigration();
|
||||
|
|
@ -19,8 +19,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_plan ON users(plan);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_plan ON users(plan);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
|
||||
|
||||
|
|
@ -42,9 +42,9 @@ CREATE TABLE IF NOT EXISTS monitors (
|
|||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_monitors_user_id ON monitors(user_id);
|
||||
CREATE INDEX idx_monitors_status ON monitors(status);
|
||||
CREATE INDEX idx_monitors_last_checked_at ON monitors(last_checked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitors_user_id ON monitors(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitors_last_checked_at ON monitors(last_checked_at);
|
||||
|
||||
-- Snapshots table
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
|
|
@ -62,9 +62,9 @@ CREATE TABLE IF NOT EXISTS snapshots (
|
|||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_monitor_id ON snapshots(monitor_id);
|
||||
CREATE INDEX idx_snapshots_created_at ON snapshots(created_at);
|
||||
CREATE INDEX idx_snapshots_changed ON snapshots(changed);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_monitor_id ON snapshots(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_created_at ON snapshots(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_changed ON snapshots(changed);
|
||||
|
||||
-- Alerts table
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
|
|
@ -81,10 +81,10 @@ CREATE TABLE IF NOT EXISTS alerts (
|
|||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_alerts_user_id ON alerts(user_id);
|
||||
CREATE INDEX idx_alerts_monitor_id ON alerts(monitor_id);
|
||||
CREATE INDEX idx_alerts_created_at ON alerts(created_at);
|
||||
CREATE INDEX idx_alerts_read_at ON alerts(read_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_user_id ON alerts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_monitor_id ON alerts(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_created_at ON alerts(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_alerts_read_at ON alerts(read_at);
|
||||
|
||||
-- Update timestamps trigger
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
|
|
@ -132,4 +132,20 @@ CREATE TABLE IF NOT EXISTS waitlist_leads (
|
|||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_email ON waitlist_leads(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_waitlist_leads_created_at ON waitlist_leads(created_at);
|
||||
|
||||
-- SEO Rankings table
|
||||
CREATE TABLE IF NOT EXISTS monitor_rankings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
keyword VARCHAR(255) NOT NULL,
|
||||
rank INTEGER, -- Null if not found in top 100
|
||||
url_found TEXT, -- The specific URL that ranked
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_monitor_id ON monitor_rankings(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_keyword ON monitor_rankings(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_rankings_created_at ON monitor_rankings(created_at);
|
||||
|
||||
-- Add seo_keywords to monitors if it doesn't exist
|
||||
ALTER TABLE monitors ADD COLUMN IF NOT EXISTS seo_keywords JSONB;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const createMonitorSchema = z.object({
|
|||
})
|
||||
)
|
||||
.optional(),
|
||||
seoKeywords: z.array(z.string()).optional(),
|
||||
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
|
||||
});
|
||||
|
||||
const updateMonitorSchema = z.object({
|
||||
|
|
@ -56,6 +58,8 @@ const updateMonitorSchema = z.object({
|
|||
})
|
||||
)
|
||||
.optional(),
|
||||
seoKeywords: z.array(z.string()).optional(),
|
||||
seoInterval: z.enum(['daily', '2d', 'weekly', 'monthly', 'off']).optional(),
|
||||
});
|
||||
|
||||
// Get plan limits
|
||||
|
|
@ -92,17 +96,21 @@ router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
|||
|
||||
const monitors = await db.monitors.findByUserId(req.user.userId);
|
||||
|
||||
// Attach recent snapshots to each monitor for sparklines
|
||||
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => {
|
||||
// Attach recent snapshots and latest rankings to each monitor
|
||||
const monitorsWithData = await Promise.all(monitors.map(async (monitor) => {
|
||||
// Get last 20 snapshots for sparkline
|
||||
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
|
||||
// Get latest rankings
|
||||
const latestRankings = await db.rankings.findLatestByMonitorId(monitor.id);
|
||||
|
||||
return {
|
||||
...monitor,
|
||||
recentSnapshots
|
||||
recentSnapshots,
|
||||
latestRankings
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({ monitors: monitorsWithSnapshots });
|
||||
res.json({ monitors: monitorsWithData });
|
||||
} catch (error) {
|
||||
console.error('List monitors error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
|
||||
|
|
@ -180,6 +188,7 @@ router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
|||
elementSelector: input.elementSelector,
|
||||
ignoreRules: input.ignoreRules,
|
||||
keywordRules: input.keywordRules,
|
||||
seoKeywords: input.seoKeywords,
|
||||
});
|
||||
|
||||
// Schedule recurring checks
|
||||
|
|
@ -354,7 +363,10 @@ router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response):
|
|||
|
||||
// Await the check so user gets immediate feedback
|
||||
try {
|
||||
await checkMonitor(monitor.id);
|
||||
const { type = 'all' } = req.body;
|
||||
const checkType = ['all', 'content', 'seo'].includes(type) ? type : 'all';
|
||||
|
||||
await checkMonitor(monitor.id, checkType === 'seo' || checkType === 'all', checkType as any);
|
||||
|
||||
// Get the latest snapshot to return to the user
|
||||
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
|
|
@ -453,6 +465,36 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
// Get monitor ranking history
|
||||
router.get('/:id/rankings', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const history = await db.rankings.findHistoryByMonitorId(req.params.id, 100);
|
||||
const latest = await db.rankings.findLatestByMonitorId(req.params.id);
|
||||
|
||||
res.json({ history, latest });
|
||||
} catch (error) {
|
||||
console.error('Get rankings error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get rankings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Export monitor audit trail (JSON or CSV)
|
||||
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -5,40 +5,120 @@ const router = Router();
|
|||
|
||||
router.get('/dynamic', (_req, res) => {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString();
|
||||
const randomValue = Math.floor(Math.random() * 1000);
|
||||
// Toggle status based on seconds (even/odd) to guarantee change
|
||||
const isNormal = now.getSeconds() % 2 === 0;
|
||||
const statusMessage = isNormal
|
||||
? "System Status: NORMAL - Everything is running smoothly."
|
||||
: "System Status: WARNING - High load detected on server node!";
|
||||
const statusColor = isNormal ? "green" : "red";
|
||||
const minute = now.getMinutes();
|
||||
const second = now.getSeconds();
|
||||
const tenSecondBlock = Math.floor(second / 10);
|
||||
|
||||
// Dynamic Pricing Logic - changes every 10 seconds
|
||||
const basicPrice = 9 + (tenSecondBlock % 5);
|
||||
const proPrice = 29 + (tenSecondBlock % 10);
|
||||
const enterprisePrice = 99 + (tenSecondBlock % 20);
|
||||
|
||||
// Dynamic Features
|
||||
const features = [
|
||||
"Unlimited Projects",
|
||||
`Up to ${10 + (tenSecondBlock % 5)} team members`,
|
||||
"Advanced Analytics",
|
||||
tenSecondBlock % 2 === 0 ? "Priority Support" : "24/7 Live Chat Support",
|
||||
second % 2 === 0 ? "Real-time Monitoring" : "Custom Webhooks"
|
||||
];
|
||||
|
||||
// Dynamic Blog Posts
|
||||
const blogPosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: tenSecondBlock % 3 === 0 ? "Scaling your SaaS in 2026" : "Growth Strategies for Modern Apps",
|
||||
author: "Jane Doe",
|
||||
date: "Jan 15, 2026"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "UI/UX Best Practices",
|
||||
author: second % 20 > 10 ? "John Smith" : "Alex Rivera",
|
||||
date: `Jan ${10 + (tenSecondBlock % 10)}, 2026`
|
||||
}
|
||||
];
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Dynamic Test Page</title>
|
||||
<meta charset="UTF-8">
|
||||
<title>CloudScale SaaS - Infrastructure for Growth</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 20px; }
|
||||
.time { font-size: 2em; color: #0066cc; }
|
||||
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; }
|
||||
body { font-family: 'Inter', -apple-system, sans-serif; line-height: 1.6; color: #333; max-width: 1000px; margin: 0 auto; padding: 40px; }
|
||||
header { text-align: center; margin-bottom: 50px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 40px 0; }
|
||||
.card { border: 1px solid #eee; border-radius: 12px; padding: 25px; text-align: center; transition: transform 0.2s; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
|
||||
.card.featured { border: 2px solid #3b82f6; background-color: #eff6ff; transform: scale(1.05); }
|
||||
.price { font-size: 3rem; font-weight: 800; margin: 20px 0; color: #111; }
|
||||
.price span { font-size: 1rem; color: #666; font-weight: normal; }
|
||||
ul { list-style: none; padding: 0; margin: 30px 0; text-align: left; }
|
||||
li { margin-bottom: 12px; display: flex; align-items: center; }
|
||||
li::before { content: "✓"; color: #10b981; font-weight: bold; margin-right: 10px; }
|
||||
.badge { background: #dcfce7; color: #166534; padding: 4px 12px; border-radius: 99px; font-size: 0.8rem; font-weight: 600; }
|
||||
.blog-section { margin-top: 60px; border-top: 1px solid #eee; padding-top: 40px; }
|
||||
.blog-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||
.post { background: #fafafa; padding: 20px; border-radius: 8px; }
|
||||
.meta { color: #888; font-size: 0.9rem; margin-bottom: 10px; }
|
||||
.footer { margin-top: 80px; text-align: center; color: #999; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Website Monitor Test</h1>
|
||||
<header>
|
||||
<h1>CloudScale <span class="badge">v2.4 Updated</span></h1>
|
||||
<p>Reliable infrastructure that scales with your business needs.</p>
|
||||
<p style="color: #6366f1; font-weight: 500;">Current Update: ${now.toLocaleTimeString()}</p>
|
||||
</header>
|
||||
|
||||
<div class="status">${statusMessage}</div>
|
||||
|
||||
<div class="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 class="pricing-section">
|
||||
<h2 style="text-align: center">Simple, Transparent Pricing</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Basic</h3>
|
||||
<div class="price">$${basicPrice}<span>/mo</span></div>
|
||||
<ul>
|
||||
<li>5 Projects</li>
|
||||
<li>Basic Analytics</li>
|
||||
<li>Community Support</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card featured">
|
||||
<h3>Pro</h3>
|
||||
<div class="price">$${proPrice}<span>/mo</span></div>
|
||||
<ul>
|
||||
${features.map(f => `<li>${f}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Enterprise</h3>
|
||||
<div class="price">$${enterprisePrice}<span>/mo</span></div>
|
||||
<ul>
|
||||
<li>Everything in Pro</li>
|
||||
<li>Custom SLAs</li>
|
||||
<li>Dedicated Account Manager</li>
|
||||
<li>White-label Branding</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="blog-section">
|
||||
<h2>From Our Blog</h2>
|
||||
<div class="blog-grid">
|
||||
${blogPosts.map(p => `
|
||||
<div class="post">
|
||||
<div class="meta">By ${p.author} • ${p.date}</div>
|
||||
<h3>${p.title}</h3>
|
||||
<p>Discover how the latest trends in technology are shaping the future of digital products...</p>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2026 CloudScale Platform. Serving ${1000 + (minute * 10)} active customers worldwide.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -20,10 +20,21 @@ router.post('/meta-preview', async (req, res) => {
|
|||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; WebsiteMonitorBot/1.0; +https://websitemonitor.com)'
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Cache-Control': 'max-age=0'
|
||||
},
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status < 500 // Resolve even if 404/403 to avoid crashing flow immediately
|
||||
timeout: 30000,
|
||||
httpAgent: new (require('http').Agent)({ family: 4, keepAlive: true }),
|
||||
httpsAgent: new (require('https').Agent)({ family: 4, rejectUnauthorized: false, keepAlive: true }),
|
||||
validateStatus: (status) => status < 500
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Router } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { query } from '../db';
|
||||
import { z } from 'zod';
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -17,14 +17,14 @@ router.post('/', async (req, res) => {
|
|||
const data = waitlistSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query(
|
||||
const existing = await query(
|
||||
'SELECT id FROM waitlist_leads WHERE email = $1',
|
||||
[data.email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Already on waitlist - return success anyway (don't reveal they're already signed up)
|
||||
const countResult = await pool.query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const position = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
return res.json({
|
||||
|
|
@ -36,13 +36,13 @@ router.post('/', async (req, res) => {
|
|||
}
|
||||
|
||||
// Insert new lead
|
||||
await pool.query(
|
||||
await query(
|
||||
'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)',
|
||||
[data.email.toLowerCase(), data.source, data.referrer || null]
|
||||
);
|
||||
|
||||
// Get current position (total count)
|
||||
const countResult = await pool.query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const position = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`);
|
||||
|
|
@ -73,7 +73,7 @@ router.post('/', async (req, res) => {
|
|||
// GET /api/waitlist/count - Get current waitlist count (public)
|
||||
router.get('/count', async (_req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const result = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const count = parseInt(result.rows[0].count, 10);
|
||||
|
||||
// Add a base number to make it look more impressive at launch
|
||||
|
|
@ -92,4 +92,40 @@ router.get('/count', async (_req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/waitlist/admin - Get waitlist leads (Admin only)
|
||||
router.get('/admin', async (req, res) => {
|
||||
try {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
const providedPassword = req.headers['x-admin-password'];
|
||||
|
||||
if (!adminPassword || providedPassword !== adminPassword) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
});
|
||||
}
|
||||
|
||||
// Get stats
|
||||
const countResult = await query('SELECT COUNT(*) FROM waitlist_leads');
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
// Get leads
|
||||
const leadsResult = await query(
|
||||
'SELECT * FROM waitlist_leads ORDER BY created_at DESC LIMIT 100'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
total,
|
||||
leads: leadsResult.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Waitlist admin error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -10,14 +10,19 @@ import {
|
|||
import { calculateChangeImportance } from './importance';
|
||||
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
|
||||
import { generateSimpleSummary, generateAISummary } from './summarizer';
|
||||
import { processSeoChecks } from './seo';
|
||||
|
||||
export interface CheckResult {
|
||||
snapshot: Snapshot;
|
||||
alertSent: boolean;
|
||||
}
|
||||
|
||||
export async function checkMonitor(monitorId: string): Promise<CheckResult | void> {
|
||||
console.log(`[Monitor] Checking monitor ${monitorId}`);
|
||||
export async function checkMonitor(
|
||||
monitorId: string,
|
||||
forceSeo = false,
|
||||
checkType: 'all' | 'content' | 'seo' = 'all'
|
||||
): Promise<{ snapshot?: Snapshot; alertSent: boolean } | void> {
|
||||
console.log(`[Monitor] Starting check: ${monitorId} | Type: ${checkType} | ForceSEO: ${forceSeo}`);
|
||||
|
||||
try {
|
||||
const monitor = await db.monitors.findById(monitorId);
|
||||
|
|
@ -28,184 +33,217 @@ export async function checkMonitor(monitorId: string): Promise<CheckResult | voi
|
|||
}
|
||||
|
||||
if (monitor.status !== 'active' && monitor.status !== 'error') {
|
||||
console.log(`[Monitor] Monitor ${monitorId} is not active or error, skipping (status: ${monitor.status})`);
|
||||
console.log(`[Monitor] Monitor ${monitorId} skipping (status: ${monitor.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch page with retries
|
||||
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
let snapshot: Snapshot | undefined;
|
||||
let changed = false;
|
||||
|
||||
// Retry on failure (max 3 attempts)
|
||||
if (fetchResult.error) {
|
||||
console.log(`[Monitor] Fetch failed, retrying... (1/3)`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
// Content Check Part
|
||||
if (checkType === 'all' || checkType === 'content') {
|
||||
console.log(`[Monitor] Running CONTENT check for ${monitor.name} (${monitor.url})`);
|
||||
// Fetch page with retries
|
||||
let fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
|
||||
// Retry on failure (max 3 attempts)
|
||||
if (fetchResult.error) {
|
||||
console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
|
||||
console.log(`[Monitor] Fetch failed, retrying... (1/3)`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for HTTP error status
|
||||
if (!fetchResult.error && fetchResult.status >= 400) {
|
||||
fetchResult.error = `HTTP ${fetchResult.status}`;
|
||||
}
|
||||
|
||||
// If still failing after retries
|
||||
if (fetchResult.error) {
|
||||
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
|
||||
|
||||
// Create error snapshot
|
||||
const failedSnapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: '',
|
||||
textContent: '',
|
||||
contentHash: '',
|
||||
httpStatus: fetchResult.status,
|
||||
responseTime: fetchResult.responseTime,
|
||||
changed: false,
|
||||
errorMessage: fetchResult.error,
|
||||
});
|
||||
|
||||
await db.monitors.incrementErrors(monitor.id);
|
||||
|
||||
// Send error alert if consecutive errors > 3
|
||||
if (monitor.consecutiveErrors >= 2) {
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
if (user) {
|
||||
await sendErrorAlert(monitor, user, fetchResult.error);
|
||||
if (fetchResult.error) {
|
||||
console.log(`[Monitor] Fetch failed, retrying... (2/3)`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTTP error status
|
||||
if (!fetchResult.error && fetchResult.status >= 400) {
|
||||
fetchResult.error = `HTTP ${fetchResult.status}`;
|
||||
}
|
||||
|
||||
return { snapshot: failedSnapshot, alertSent: false };
|
||||
}
|
||||
// If still failing after retries
|
||||
if (fetchResult.error) {
|
||||
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
|
||||
|
||||
// Apply noise filters
|
||||
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules));
|
||||
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
|
||||
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
|
||||
// Create error snapshot
|
||||
const failedSnapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: '',
|
||||
textContent: '',
|
||||
contentHash: '',
|
||||
httpStatus: fetchResult.status,
|
||||
responseTime: fetchResult.responseTime,
|
||||
changed: false,
|
||||
errorMessage: fetchResult.error,
|
||||
});
|
||||
|
||||
// Get previous snapshot
|
||||
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
await db.monitors.incrementErrors(monitor.id);
|
||||
|
||||
let changed = false;
|
||||
let changePercentage = 0;
|
||||
let diffResult: ReturnType<typeof compareDiffs> | undefined;
|
||||
// Send error alert if consecutive errors > 3
|
||||
if (monitor.consecutiveErrors >= 2) {
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
if (user) {
|
||||
await sendErrorAlert(monitor, user, fetchResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (previousSnapshot) {
|
||||
// Apply same filters to previous content for fair comparison
|
||||
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
|
||||
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
|
||||
return { snapshot: failedSnapshot, alertSent: false };
|
||||
}
|
||||
|
||||
// Compare
|
||||
diffResult = compareDiffs(previousHtml, processedHtml);
|
||||
changed = diffResult.changed;
|
||||
changePercentage = diffResult.changePercentage;
|
||||
// Apply noise filters
|
||||
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
|
||||
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
|
||||
|
||||
console.log(
|
||||
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
|
||||
);
|
||||
// Get previous snapshot
|
||||
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
|
||||
// Check keywords
|
||||
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
|
||||
const keywordMatches = checkKeywords(
|
||||
previousHtml,
|
||||
processedHtml,
|
||||
monitor.keywordRules
|
||||
let changePercentage = 0;
|
||||
let diffResult: ReturnType<typeof compareDiffs> | undefined;
|
||||
|
||||
if (previousSnapshot) {
|
||||
// Apply same filters to previous content for fair comparison
|
||||
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
|
||||
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
|
||||
|
||||
// Compare
|
||||
diffResult = compareDiffs(previousHtml, processedHtml);
|
||||
changed = diffResult.changed;
|
||||
changePercentage = diffResult.changePercentage;
|
||||
|
||||
console.log(
|
||||
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
|
||||
);
|
||||
|
||||
if (keywordMatches.length > 0) {
|
||||
console.log(`[Monitor] Keyword matches found:`, keywordMatches);
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
// Check keywords
|
||||
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
|
||||
const keywordMatches = checkKeywords(
|
||||
previousHtml,
|
||||
processedHtml,
|
||||
monitor.keywordRules
|
||||
);
|
||||
|
||||
if (user) {
|
||||
for (const match of keywordMatches) {
|
||||
await sendKeywordAlert(monitor, user, match);
|
||||
if (keywordMatches.length > 0) {
|
||||
console.log(`[Monitor] Keyword matches found:`, keywordMatches);
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
|
||||
if (user) {
|
||||
for (const match of keywordMatches) {
|
||||
await sendKeywordAlert(monitor, user, match);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First check - consider it as "changed" to create baseline
|
||||
changed = true;
|
||||
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
|
||||
}
|
||||
} else {
|
||||
// First check - consider it as "changed" to create baseline
|
||||
changed = true;
|
||||
console.log(`[Monitor] First check for ${monitor.name}, creating baseline`);
|
||||
}
|
||||
|
||||
// Generate human-readable summary (Hybrid approach)
|
||||
let summary: string | undefined;
|
||||
// Generate human-readable summary (Hybrid approach)
|
||||
let summary: string | undefined;
|
||||
|
||||
if (changed && previousSnapshot && diffResult) {
|
||||
// Hybrid logic: AI for changes (≥5%), simple for very small changes
|
||||
if (changePercentage >= 5) {
|
||||
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
|
||||
try {
|
||||
summary = await generateAISummary(diffResult.diff, changePercentage);
|
||||
} catch (error) {
|
||||
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
|
||||
if (changed && previousSnapshot && diffResult) {
|
||||
// Hybrid logic: AI for changes (≥5%), simple for very small changes
|
||||
if (changePercentage >= 5) {
|
||||
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
|
||||
try {
|
||||
summary = await generateAISummary(diffResult.diff, changePercentage, monitor.url);
|
||||
} catch (error) {
|
||||
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
|
||||
summary = generateSimpleSummary(
|
||||
diffResult.diff,
|
||||
previousSnapshot.htmlContent,
|
||||
fetchResult.html
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
|
||||
summary = generateSimpleSummary(
|
||||
diffResult.diff,
|
||||
previousSnapshot.htmlContent,
|
||||
fetchResult.html
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
|
||||
summary = generateSimpleSummary(
|
||||
diffResult.diff,
|
||||
previousSnapshot.htmlContent,
|
||||
fetchResult.html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
const snapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: fetchResult.html,
|
||||
textContent: fetchResult.text,
|
||||
contentHash: fetchResult.hash,
|
||||
httpStatus: fetchResult.status,
|
||||
responseTime: fetchResult.responseTime,
|
||||
changed,
|
||||
changePercentage: changed ? changePercentage : undefined,
|
||||
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
|
||||
summary,
|
||||
});
|
||||
// Create snapshot
|
||||
console.log(`[Monitor] Creating snapshot in DB for ${monitor.name}`);
|
||||
snapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: fetchResult.html,
|
||||
textContent: fetchResult.text,
|
||||
contentHash: fetchResult.hash,
|
||||
httpStatus: fetchResult.status,
|
||||
responseTime: fetchResult.responseTime,
|
||||
changed,
|
||||
changePercentage: changed ? changePercentage : undefined,
|
||||
importanceScore: changed ? await calculateChangeImportance(monitor.id, changePercentage, 0, processedHtml) : 0,
|
||||
summary,
|
||||
});
|
||||
|
||||
// Update monitor
|
||||
await db.monitors.updateLastChecked(monitor.id, changed);
|
||||
// Update monitor
|
||||
await db.monitors.updateLastChecked(monitor.id, changed);
|
||||
|
||||
// Send alert if changed and not first check
|
||||
if (changed && previousSnapshot) {
|
||||
try {
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
if (user) {
|
||||
await sendChangeAlert(monitor, user, snapshot, changePercentage);
|
||||
// Send alert if changed and not first check
|
||||
if (changed && previousSnapshot) {
|
||||
try {
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
if (user) {
|
||||
await sendChangeAlert(monitor, user, snapshot, changePercentage);
|
||||
}
|
||||
} catch (alertError) {
|
||||
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
|
||||
}
|
||||
} catch (alertError) {
|
||||
console.error(`[Monitor] Failed to send alert for ${monitor.id}:`, alertError);
|
||||
// Continue execution - do not fail the check
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old snapshots based on user plan retention period
|
||||
try {
|
||||
const retentionUser = await db.users.findById(monitor.userId);
|
||||
if (retentionUser) {
|
||||
const { getRetentionDays } = await import('../config');
|
||||
const retentionDays = getRetentionDays(retentionUser.plan);
|
||||
await db.snapshots.deleteOldSnapshotsByAge(monitor.id, retentionDays);
|
||||
// SEO Check Part
|
||||
if ((checkType === 'all' || checkType === 'seo') && monitor.seoKeywords && monitor.seoKeywords.length > 0) {
|
||||
let shouldRunSeo = false;
|
||||
|
||||
if (forceSeo) {
|
||||
console.log(`[Monitor] SEO check triggered manually for ${monitor.name}`);
|
||||
shouldRunSeo = true;
|
||||
} else if (monitor.seoInterval && monitor.seoInterval !== 'off') {
|
||||
if (!monitor.lastSeoCheckAt) {
|
||||
shouldRunSeo = true;
|
||||
} else {
|
||||
const hoursSinceLast = (Date.now() - new Date(monitor.lastSeoCheckAt).getTime()) / (1000 * 60 * 60);
|
||||
|
||||
switch (monitor.seoInterval) {
|
||||
case 'daily': shouldRunSeo = hoursSinceLast >= 24; break;
|
||||
case '2d': shouldRunSeo = hoursSinceLast >= 48; break;
|
||||
case 'weekly': shouldRunSeo = hoursSinceLast >= 168; break;
|
||||
case 'monthly': shouldRunSeo = hoursSinceLast >= 720; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunSeo) {
|
||||
console.log(`[Monitor] Running SEO check for ${monitor.name} (Schedule: ${monitor.seoInterval})`);
|
||||
|
||||
// Update last_seo_check_at immediately to prevent double scheduling if slow
|
||||
await db.monitors.update(monitor.id, { lastSeoCheckAt: new Date() });
|
||||
|
||||
if (forceSeo) {
|
||||
// Await SEO check if explicitly forced (manual trigger)
|
||||
await processSeoChecks(monitor.id, monitor.url, monitor.seoKeywords);
|
||||
} else {
|
||||
// Run in background for scheduled checks
|
||||
processSeoChecks(monitor.id, monitor.url, monitor.seoKeywords)
|
||||
.catch(err => console.error(`[Monitor] SEO check failed for ${monitor.name}:`, err));
|
||||
}
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error(`[Monitor] Failed to cleanup snapshots for ${monitor.id}:`, cleanupError);
|
||||
}
|
||||
|
||||
console.log(`[Monitor] Check completed for ${monitor.name}`);
|
||||
return { snapshot, alertSent: changed && !!previousSnapshot };
|
||||
console.log(`[Monitor] Check completed for ${monitor.name} (Snapshot created: ${!!snapshot})`);
|
||||
return {
|
||||
snapshot,
|
||||
alertSent: changed
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
|
||||
await db.monitors.incrementErrors(monitorId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { getJson } from 'serpapi';
|
||||
import db from '../db';
|
||||
|
||||
interface RankingResult {
|
||||
rank: number | null;
|
||||
foundUrl: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkRanking(keyword: string, targetUrl: string): Promise<RankingResult> {
|
||||
const apiKey = process.env.SERPAPI_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
console.error('SERPAPI_KEY is missing');
|
||||
return { rank: null, foundUrl: null, error: 'SERPAPI_KEY missing' };
|
||||
}
|
||||
|
||||
// Domain normalization for easier matching
|
||||
// Removes protocol, www, and trailing slashes
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url.startsWith('http') ? url : `https://${url}`);
|
||||
return u.hostname.replace('www.', '') + u.pathname.replace(/\/$/, '');
|
||||
} catch (e) {
|
||||
return url.replace('www.', '').replace(/\/$/, '');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedTarget = normalizeUrl(targetUrl);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
getJson(
|
||||
{
|
||||
engine: 'google',
|
||||
q: keyword,
|
||||
api_key: apiKey,
|
||||
num: 100, // Check top 100 results
|
||||
},
|
||||
(json: any) => {
|
||||
if (json.error) {
|
||||
console.error('SerpApi error:', json.error);
|
||||
resolve({ rank: null, foundUrl: null, error: json.error });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!json.organic_results) {
|
||||
resolve({ rank: null, foundUrl: null });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of json.organic_results) {
|
||||
const resultUrl = result.link;
|
||||
const normalizedResult = normalizeUrl(resultUrl);
|
||||
|
||||
// Check if result contains our target domain
|
||||
if (normalizedResult.includes(normalizedTarget) || normalizedTarget.includes(normalizedResult)) {
|
||||
resolve({
|
||||
rank: result.position,
|
||||
foundUrl: resultUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ rank: null, foundUrl: null });
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('SeoService error:', error);
|
||||
resolve({ rank: null, foundUrl: null, error: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function processSeoChecks(monitorId: string, url: string, keywords: string[]) {
|
||||
if (!keywords || keywords.length === 0) return;
|
||||
|
||||
console.log(`[SEO] Starting checks for monitor ${monitorId} (${keywords.length} keywords)`);
|
||||
|
||||
for (const keyword of keywords) {
|
||||
const result = await checkRanking(keyword, url);
|
||||
|
||||
if (result.rank !== null || result.foundUrl !== null) {
|
||||
console.log(`[SEO] Found rank ${result.rank} for "${keyword}"`);
|
||||
} else {
|
||||
console.log(`[SEO] Not found in top 100 for "${keyword}"`);
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
await db.rankings.create({
|
||||
monitorId,
|
||||
keyword,
|
||||
rank: result.rank,
|
||||
urlFound: result.foundUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -76,11 +76,11 @@ export function generateSimpleSummary(
|
|||
|
||||
// Add text preview if available
|
||||
if (textPreviews.length > 0) {
|
||||
const previewText = textPreviews.slice(0, 2).join(' → ');
|
||||
const previewText = textPreviews.join(' | ');
|
||||
if (summary) {
|
||||
summary += `. Changed: "${previewText}"`;
|
||||
summary += `. Details: ${previewText}`;
|
||||
} else {
|
||||
summary = `Text changed: "${previewText}"`;
|
||||
summary = `Changes: ${previewText}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +213,8 @@ function countRemovedElements(htmlOld: string, htmlNew: string, tag: string): nu
|
|||
*/
|
||||
export async function generateAISummary(
|
||||
diff: Change[],
|
||||
changePercentage: number
|
||||
changePercentage: number,
|
||||
url?: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Check if API key is configured
|
||||
|
|
@ -229,17 +230,30 @@ export async function generateAISummary(
|
|||
// Format diff for AI (reduce token count)
|
||||
const formattedDiff = formatDiffForAI(diff);
|
||||
|
||||
const prompt = `Analyze this website change and create a concise summary for non-programmers.
|
||||
Focus on IMPORTANT changes only. Medium detail level.
|
||||
const prompt = `Analyze the website changes for: ${url || 'unknown'}
|
||||
You are an expert content monitor. Your task is to provide a high-quality, professional summary of what changed on this page.
|
||||
|
||||
Change percentage: ${changePercentage.toFixed(2)}%
|
||||
GOAL:
|
||||
Categorize changes by page section and describe their impact on the user.
|
||||
|
||||
Diff:
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Identify the SECTION: Look at the tags and context. Is it the "Pricing Table", "Feature List", "Hero Section", "Blog Feed", or "Footer"? Mention it clearly.
|
||||
2. Be SPECIFIC: Instead of "Pricing updated", say "The Pro Plan monthly price increased from $29 to $34".
|
||||
3. CONTEXTUALIZE: Group related changes together. For example, "Updated the 'CloudScale v2.4' header and refreshed the blog post titles in the feed section."
|
||||
4. NO JARGON: Avoid terms like "HTML", "div", "CSS", "selectors". Talk to the user, not a developer.
|
||||
5. TONE: Professional, concise, and helpful.
|
||||
|
||||
Change magnitude: ${changePercentage.toFixed(2)}%
|
||||
|
||||
DIFF DATA TO ANALYZE:
|
||||
${formattedDiff}
|
||||
|
||||
Format: "Section name: What changed. Details if important."
|
||||
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer."
|
||||
Keep it under 100 words. Be specific about what changed, not how.`;
|
||||
FORMAT:
|
||||
- Start with a single summary sentence.
|
||||
- Use DOUBLE NEWLINES between different sections (e.g., between Pricing and Blog).
|
||||
- Each bullet point MUST be on its own new line.
|
||||
- Use bold headers for sections like **Pricing Table:** or **Header Update:**.
|
||||
- Limit response to 150 words.`;
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model: 'gpt-4o-mini', // Fastest, cheapest
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export interface Monitor {
|
|||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
seoKeywords?: string[];
|
||||
seoInterval?: string;
|
||||
lastSeoCheckAt?: Date;
|
||||
lastCheckedAt?: Date;
|
||||
lastChangedAt?: Date;
|
||||
consecutiveErrors: number;
|
||||
|
|
@ -98,6 +101,7 @@ export interface CreateMonitorInput {
|
|||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
seoInterval?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMonitorInput {
|
||||
|
|
@ -107,4 +111,5 @@ export interface UpdateMonitorInput {
|
|||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
seoInterval?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Lock, Loader2, ArrowLeft, RefreshCw, Calendar, Mail, Globe } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Lead {
|
||||
id: string
|
||||
email: string
|
||||
source: string
|
||||
referrer: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [data, setData] = useState<{ total: number; leads: Lead[] } | null>(null)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
|
||||
headers: {
|
||||
'x-admin-password': password
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setIsAuthenticated(true)
|
||||
setData(result)
|
||||
} else {
|
||||
setError('Invalid password')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Connection error')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'}/api/waitlist/admin`, {
|
||||
headers: {
|
||||
'x-admin-password': password
|
||||
}
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.success) setData(result)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// LOGIN SCREEN
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-sm"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors mb-6">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Website
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-foreground">Admin Access</h1>
|
||||
<p className="text-muted-foreground mt-2">Enter password to view waitlist</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-xl">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full h-10 pl-10 pr-4 rounded-lg border border-border bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
|
||||
placeholder="Password"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 font-medium text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Login'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DASHBOARD
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary/10 p-6 md:p-12">
|
||||
<div className="max-w-5xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Waitlist Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Real-time stats and signups</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={refreshData} disabled={isLoading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setIsAuthenticated(false)}>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-card border border-border p-6 rounded-xl shadow-sm"
|
||||
>
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Total Signups</h3>
|
||||
<div className="mt-2 text-4xl font-bold text-[hsl(var(--primary))]">
|
||||
{data?.total || 0}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-card border border-border p-6 rounded-xl shadow-sm"
|
||||
>
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Latest Signup</h3>
|
||||
<div className="mt-2 text-lg font-medium text-foreground truncate">
|
||||
{data?.leads[0]?.email || 'N/A'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{data?.leads[0] ? new Date(data.leads[0].created_at).toLocaleString() : '-'}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Listings Table */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-card border border-border rounded-xl shadow-sm overflow-hidden"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border bg-secondary/5">
|
||||
<h3 className="font-semibold text-foreground">Recent Signups</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-secondary/20 text-muted-foreground font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Email</th>
|
||||
<th className="px-6 py-3">Source</th>
|
||||
<th className="px-6 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data?.leads.map((lead) => (
|
||||
<tr key={lead.id} className="hover:bg-secondary/5 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-foreground flex items-center gap-2">
|
||||
<Mail className="h-3 w-3 text-muted-foreground" />
|
||||
{lead.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground">
|
||||
{lead.source}
|
||||
{lead.referrer && <span className="text-xs ml-2 opacity-70">via {new URL(lead.referrer).hostname}</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-muted-foreground whitespace-nowrap">
|
||||
{new Date(lead.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.leads.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-6 py-8 text-center text-muted-foreground">
|
||||
No signups yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Footer } from '@/components/layout/Footer'
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<div className="flex-1 py-24 px-6">
|
||||
<div className="mx-auto max-w-5xl space-y-12">
|
||||
<div className="space-y-4">
|
||||
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Home
|
||||
</Link>
|
||||
<h1 className="text-4xl md:text-5xl font-bold font-display text-foreground">Blog</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl">
|
||||
Latest updates, guides, and insights from the Alertify team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{/* Placeholder for empty state */}
|
||||
<div className="col-span-full py-12 text-center border rounded-2xl border-dashed border-border bg-secondary/5">
|
||||
<p className="text-muted-foreground">No posts published yet. Stay tuned!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 982 KiB |
|
|
@ -18,12 +18,15 @@ const spaceGrotesk = Space_Grotesk({
|
|||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
title: 'Alertify - Track Changes on Any Website',
|
||||
description: 'Alertify helps you track website changes in real-time. Get notified instantly when content updates.',
|
||||
}
|
||||
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import { PostHogProvider } from '@/components/analytics/PostHogProvider'
|
||||
import { CookieBanner } from '@/components/compliance/CookieBanner'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
|
|
@ -32,8 +35,11 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
|
||||
<body className={interTight.className}>
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster richColors position="top-right" />
|
||||
<PostHogProvider>
|
||||
<Providers>{children}</Providers>
|
||||
<CookieBanner />
|
||||
<Toaster richColors position="top-right" />
|
||||
</PostHogProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -40,30 +41,18 @@ export default function LoginPage() {
|
|||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Alertify Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your Website Monitor account
|
||||
Sign in to your Alertify account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
|
|
@ -7,13 +8,17 @@ import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { SEORankingCard } from '@/components/seo-ranking-card'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [isCheckingSeo, setIsCheckingSeo] = useState(false)
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
const { data: monitorData, refetch: refetchMonitor } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
|
|
@ -21,7 +26,7 @@ export default function MonitorHistoryPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
const { data: historyData, isLoading, refetch: refetchHistory } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
|
|
@ -29,6 +34,41 @@ export default function MonitorHistoryPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const handleCheckNow = async (type: 'content' | 'seo' = 'content') => {
|
||||
if (type === 'seo') {
|
||||
if (isCheckingSeo) return
|
||||
setIsCheckingSeo(true)
|
||||
} else {
|
||||
if (isChecking) return
|
||||
setIsChecking(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await monitorAPI.check(id, type)
|
||||
|
||||
if (type === 'seo') {
|
||||
toast.success('SEO Ranking check completed')
|
||||
} else {
|
||||
if (result.snapshot?.errorMessage) {
|
||||
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
|
||||
} else {
|
||||
toast.success(result.snapshot?.changed ? 'Changes detected!' : 'No changes detected')
|
||||
}
|
||||
}
|
||||
refetchMonitor()
|
||||
refetchHistory()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
toast.error(`Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
|
||||
} finally {
|
||||
if (type === 'seo') {
|
||||
setIsCheckingSeo(false)
|
||||
} else {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
|
|
@ -66,6 +106,40 @@ export default function MonitorHistoryPage() {
|
|||
</div>
|
||||
{monitor && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow('content')}
|
||||
disabled={isChecking || isCheckingSeo}
|
||||
className="gap-2"
|
||||
>
|
||||
{isChecking ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
Check Now
|
||||
</Button>
|
||||
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow('seo')}
|
||||
disabled={isChecking || isCheckingSeo}
|
||||
className="gap-2 border-purple-200 text-purple-700 hover:bg-purple-50"
|
||||
>
|
||||
{isCheckingSeo ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-purple-600 border-t-transparent" />
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
)}
|
||||
SEO Check
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -105,6 +179,19 @@ export default function MonitorHistoryPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Rankings */}
|
||||
{monitor && monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4 text-purple-700">
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<h2 className="text-lg font-semibold">SEO Keyword Performance</h2>
|
||||
</div>
|
||||
<SEORankingCard monitorId={id} keywords={monitor.seoKeywords} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History List */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
|
||||
|
|
@ -181,7 +268,7 @@ export default function MonitorHistoryPage() {
|
|||
{snapshot.summary && (
|
||||
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
|
||||
<p className="font-medium text-foreground mb-1">Summary</p>
|
||||
<p>{snapshot.summary}</p>
|
||||
<p className="whitespace-pre-wrap break-words leading-relaxed mt-2">{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export default function SnapshotDetailsPage() {
|
|||
{snapshot.summary && (
|
||||
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<p className="text-sm font-medium text-blue-900">Change Summary</p>
|
||||
<p className="text-sm text-blue-700 mt-1">{snapshot.summary}</p>
|
||||
<p className="text-sm text-blue-700 mt-2 whitespace-pre-wrap break-words leading-relaxed">{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export default function MonitorsPage() {
|
|||
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [checkingId, setCheckingId] = useState<string | null>(null)
|
||||
const [checkingSeoId, setCheckingSeoId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
|
||||
|
|
@ -102,6 +103,8 @@ export default function MonitorsPage() {
|
|||
threshold?: number
|
||||
caseSensitive?: boolean
|
||||
}>,
|
||||
seoKeywords: [] as string[],
|
||||
seoInterval: 'off',
|
||||
})
|
||||
const [showVisualSelector, setShowVisualSelector] = useState(false)
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
|
|
@ -131,6 +134,10 @@ export default function MonitorsPage() {
|
|||
if (newMonitor.keywordRules.length > 0) {
|
||||
payload.keywordRules = newMonitor.keywordRules
|
||||
}
|
||||
if (newMonitor.seoKeywords.length > 0) {
|
||||
payload.seoKeywords = newMonitor.seoKeywords
|
||||
payload.seoInterval = newMonitor.seoInterval
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await monitorAPI.update(editingId, payload)
|
||||
|
|
@ -146,7 +153,9 @@ export default function MonitorsPage() {
|
|||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
keywordRules: [],
|
||||
seoKeywords: [],
|
||||
seoInterval: 'off',
|
||||
})
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
|
|
@ -179,7 +188,9 @@ export default function MonitorsPage() {
|
|||
frequency: monitor.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitor.keywordRules || []
|
||||
keywordRules: monitor.keywordRules || [],
|
||||
seoKeywords: monitor.seoKeywords || [],
|
||||
seoInterval: monitor.seoInterval || 'off',
|
||||
})
|
||||
setEditingId(monitor.id)
|
||||
setShowAddForm(true)
|
||||
|
|
@ -194,7 +205,9 @@ export default function MonitorsPage() {
|
|||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
keywordRules: [],
|
||||
seoKeywords: [],
|
||||
seoInterval: 'off',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -223,38 +236,55 @@ export default function MonitorsPage() {
|
|||
frequency: monitorData.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitorData.keywordRules as any[]
|
||||
keywordRules: monitorData.keywordRules as any[],
|
||||
seoKeywords: [],
|
||||
seoInterval: 'off',
|
||||
})
|
||||
setShowTemplates(false)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
// Prevent multiple simultaneous checks
|
||||
if (checkingId !== null) return
|
||||
const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
|
||||
// Prevent multiple simultaneous checks of the same type
|
||||
if (type === 'seo') {
|
||||
if (checkingSeoId !== null) return
|
||||
setCheckingSeoId(id)
|
||||
} else {
|
||||
if (checkingId !== null) return
|
||||
setCheckingId(id)
|
||||
}
|
||||
|
||||
setCheckingId(id)
|
||||
try {
|
||||
const result = await monitorAPI.check(id)
|
||||
if (result.snapshot?.errorMessage) {
|
||||
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
|
||||
} else if (result.snapshot?.changed) {
|
||||
toast.success('Changes detected!', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => router.push(`/monitors/${id}`)
|
||||
}
|
||||
})
|
||||
const result = await monitorAPI.check(id, type)
|
||||
|
||||
if (type === 'seo') {
|
||||
toast.success('SEO Ranking check completed')
|
||||
// For SEO check, we might want to refresh rankings specifically if we had a way
|
||||
} else {
|
||||
toast.info('No changes detected')
|
||||
if (result.snapshot?.errorMessage) {
|
||||
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
|
||||
} else if (result.snapshot?.changed) {
|
||||
toast.success('Changes detected!', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => router.push(`/monitors/${id}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.info('No changes detected')
|
||||
}
|
||||
}
|
||||
refetch()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
toast.error(err.response?.data?.message || 'Failed to check monitor')
|
||||
toast.error(err.response?.data?.message || `Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
|
||||
} finally {
|
||||
setCheckingId(null)
|
||||
if (type === 'seo') {
|
||||
setCheckingSeoId(null)
|
||||
} else {
|
||||
setCheckingId(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -646,6 +676,80 @@ export default function MonitorsPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* SEO Keywords Section */}
|
||||
<div className="space-y-3 rounded-lg border border-purple-500/20 bg-purple-500/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">SEO Tracking</h4>
|
||||
<p className="text-xs text-muted-foreground">Track Google ranking for specific keywords</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
label=""
|
||||
value={newMonitor.seoInterval}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, seoInterval: e.target.value })}
|
||||
options={[
|
||||
{ value: 'off', label: 'Manual Check Only' },
|
||||
{ value: 'daily', label: 'Check Daily' },
|
||||
{ value: '2d', label: 'Every 2 Days' },
|
||||
{ value: 'weekly', label: 'Check Weekly' },
|
||||
{ value: 'monthly', label: 'Check Monthly' }
|
||||
]}
|
||||
className="w-40"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-purple-200 hover:bg-purple-50 hover:text-purple-700"
|
||||
onClick={() => {
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
seoKeywords: [...newMonitor.seoKeywords, '']
|
||||
})
|
||||
}}
|
||||
>
|
||||
+ Add Keyword
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newMonitor.seoKeywords.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No SEO keywords configured.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newMonitor.seoKeywords.map((keyword, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.seoKeywords]
|
||||
updated[index] = e.target.value
|
||||
setNewMonitor({ ...newMonitor, seoKeywords: updated })
|
||||
}}
|
||||
placeholder="e.g. best coffee in austin"
|
||||
className="flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = newMonitor.seoKeywords.filter((_, i) => i !== index)
|
||||
setNewMonitor({ ...newMonitor, seoKeywords: updated })
|
||||
}}
|
||||
className="rounded p-2 text-red-500 hover:bg-red-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">
|
||||
{editingId ? 'Save Changes' : 'Create Monitor'}
|
||||
|
|
@ -714,16 +818,41 @@ export default function MonitorsPage() {
|
|||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
{/* SEO Status */}
|
||||
{monitor.seoInterval && monitor.seoInterval !== 'off' && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-purple-50 p-3 text-center text-xs border border-purple-100">
|
||||
<div>
|
||||
<p className="font-semibold text-purple-700">{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}</p>
|
||||
<p className="text-purple-600/80">SEO Check</p>
|
||||
</div>
|
||||
<div>
|
||||
{monitor.lastSeoCheckAt ? (
|
||||
<>
|
||||
<p className="font-semibold text-purple-700">
|
||||
{new Date(monitor.lastSeoCheckAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-purple-600/80">Last SEO</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-purple-700">-</p>
|
||||
<p className="text-purple-600/80">Last SEO</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-muted-foreground">Frequency</p>
|
||||
</div>
|
||||
<div>
|
||||
{monitor.last_changed_at ? (
|
||||
{monitor.lastChangedAt ? (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">
|
||||
{new Date(monitor.last_changed_at).toLocaleDateString()}
|
||||
{new Date(monitor.lastChangedAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
|
|
@ -737,9 +866,9 @@ export default function MonitorsPage() {
|
|||
</div>
|
||||
|
||||
{/* Last Checked */}
|
||||
{monitor.last_checked_at ? (
|
||||
{monitor.lastCheckedAt ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
|
||||
Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
|
|
@ -747,9 +876,26 @@ export default function MonitorsPage() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* SEO Rankings */}
|
||||
{monitor.latestRankings && monitor.latestRankings.length > 0 && (
|
||||
<div className="mb-4 space-y-1">
|
||||
<p className="text-[10px] font-medium text-purple-600 uppercase tracking-wider">Top Rankings</p>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between text-[11px] bg-purple-50/50 rounded px-2 py-1 border border-purple-100/50">
|
||||
<span className="truncate max-w-[140px] text-purple-900 font-medium">{r.keyword}</span>
|
||||
<Badge variant="outline" className="bg-white border-purple-200 text-purple-700 h-4 px-1 text-[9px] leading-none min-w-[30px] justify-center">
|
||||
#{r.rank || '100+'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-primary/40 pl-2 line-clamp-2">
|
||||
"{monitor.recentSnapshots[0].summary}"
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -784,12 +930,24 @@ export default function MonitorsPage() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
onClick={() => handleCheckNow(monitor.id, 'content')}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50"
|
||||
onClick={() => handleCheckNow(monitor.id, 'seo')}
|
||||
loading={checkingSeoId === monitor.id}
|
||||
disabled={checkingSeoId !== null}
|
||||
>
|
||||
{checkingSeoId === monitor.id ? 'SEO Checking...' : 'Check SEO'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -887,7 +1045,7 @@ export default function MonitorsPage() {
|
|||
)}
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
|
||||
{monitor.lastChangedAt ? new Date(monitor.lastChangedAt).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
<p className="text-xs">Last Change</p>
|
||||
</div>
|
||||
|
|
@ -898,12 +1056,25 @@ export default function MonitorsPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
onClick={() => handleCheckNow(monitor.id, 'content')}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-purple-200 text-purple-700 hover:bg-purple-50"
|
||||
onClick={() => handleCheckNow(monitor.id, 'seo')}
|
||||
loading={checkingSeoId === monitor.id}
|
||||
disabled={checkingSeoId !== null}
|
||||
title="Check SEO Rankings"
|
||||
>
|
||||
{checkingSeoId === monitor.id ? 'Checking SEO...' : 'SEO'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
|
||||
Edit
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||
import { HeroSection } from '@/components/landing/LandingSections'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Footer } from '@/components/layout/Footer'
|
||||
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
|
||||
|
||||
// Dynamic imports for performance optimization (lazy loading)
|
||||
|
|
@ -78,10 +80,10 @@ export default function Home() {
|
|||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 transition-all duration-200">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary transition-transform group-hover:scale-110 shadow-lg shadow-primary/20">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
<div className="relative h-8 w-8 transition-transform group-hover:scale-110">
|
||||
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">Alertify</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
|
||||
|
|
@ -202,67 +204,7 @@ export default function Home() {
|
|||
<FinalCTA />
|
||||
|
||||
{/* Footer */}
|
||||
< footer className="border-t border-border bg-background py-12 text-sm" >
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">MonitorTool</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-xs mb-6">
|
||||
The modern platform for uptime monitoring, change detection, and performance tracking.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Social icons placeholders */}
|
||||
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#features" className="hover:text-primary transition-colors">Features</Link></li>
|
||||
<li><Link href="#use-cases" className="hover:text-primary transition-colors">Use Cases</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">About</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Blog</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Careers</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Contact</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Privacy</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Terms</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Cookie Policy</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
||||
<p>© 2026 MonitorTool. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer >
|
||||
<Footer />
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Footer } from '@/components/layout/Footer'
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<div className="flex-1 py-24 px-6">
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Home
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold font-display text-foreground">Privacy Policy</h1>
|
||||
<p className="text-muted-foreground">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 prose prose-neutral dark:prose-invert max-w-none">
|
||||
<h3>1. Introduction</h3>
|
||||
<p>
|
||||
Welcome to Alertify. We respect your privacy and are committed to protecting your personal data.
|
||||
This privacy policy will inform you as to how we look after your personal data when you visit our website
|
||||
and tell you about your privacy rights and how the law protects you.
|
||||
</p>
|
||||
|
||||
<h3>2. Data We Collect</h3>
|
||||
<p>
|
||||
We may collect, use, store and transfer different kinds of personal data about you which we have grouped together follows:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
||||
<li>Identity Data: includes email address.</li>
|
||||
<li>Technical Data: includes internet protocol (IP) address, browser type and version, time zone setting and location.</li>
|
||||
<li>Usage Data: includes information about how you use our website and services.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. How We Use Your Data</h3>
|
||||
<p>
|
||||
We will only use your personal data when the law allows us to. Most commonly, we will use your personal data in the following circumstances:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 space-y-2 text-muted-foreground">
|
||||
<li>To provide the service you signed up for (Waitlist, Monitoring).</li>
|
||||
<li>To manage our relationship with you.</li>
|
||||
<li>To improve our website, products/services, marketing and customer relationships.</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. Contact Us</h3>
|
||||
<p>
|
||||
If you have any questions about this privacy policy or our privacy practices, please contact us at:
|
||||
</p>
|
||||
<div className="p-4 bg-secondary/20 rounded-lg border border-border">
|
||||
<p className="font-semibold">Alertify Support</p>
|
||||
<p>Email: <a href="mailto:support@qrmaster.net" className="text-primary hover:underline">support@qrmaster.net</a></p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -59,21 +60,14 @@ export default function RegisterPage() {
|
|||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center relative">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Alertify Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
'use client'
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect, Suspense } from "react"
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
|
||||
function PostHogPageViewContent() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const posthog = usePostHog()
|
||||
|
||||
useEffect(() => {
|
||||
// Track pageview
|
||||
if (pathname && posthog) {
|
||||
let url = window.origin + pathname
|
||||
if (searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`
|
||||
}
|
||||
posthog.capture('$pageview', {
|
||||
'$current_url': url,
|
||||
})
|
||||
}
|
||||
}, [pathname, searchParams, posthog])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function PostHogPageView() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<PostHogPageViewContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
'use client'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
||||
import { useEffect } from 'react'
|
||||
import PostHogPageView from './PostHogPageView'
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && !posthog.__loaded) {
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY || 'phc_placeholder_key', {
|
||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
|
||||
capture_pageview: false, // Disable automatic pageview capture, as we handle it manually
|
||||
capture_pageleave: true,
|
||||
persistence: 'localStorage+cookie',
|
||||
opt_out_capturing_by_default: true,
|
||||
debug: true,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <PHProvider client={posthog}>
|
||||
<PostHogPageView />
|
||||
{children}
|
||||
</PHProvider>
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import posthog from 'posthog-js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { Cookie } from 'lucide-react'
|
||||
|
||||
export function CookieBanner() {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const optedIn = posthog.has_opted_in_capturing()
|
||||
const optedOut = posthog.has_opted_out_capturing()
|
||||
|
||||
if (!optedIn && !optedOut) {
|
||||
setShow(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAccept = () => {
|
||||
posthog.opt_in_capturing()
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
const handleDecline = () => {
|
||||
posthog.opt_out_capturing()
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="fixed bottom-4 right-4 z-[100] max-w-sm w-full p-4"
|
||||
>
|
||||
<div className="rounded-xl border border-border bg-background/95 p-6 shadow-2xl backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-primary/10 p-2 text-primary">
|
||||
<Cookie className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">We value your privacy</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||
We use cookies to enhance your browsing experience and analyze our traffic. By clicking "Accept", you consent to our use of cookies.
|
||||
Read our <Link href="/privacy" className="underline hover:text-foreground">Privacy Policy</Link>.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={handleDecline} className="flex-1">
|
||||
Decline
|
||||
</Button>
|
||||
<Button onClick={handleAccept} className="flex-1">
|
||||
Accept
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -36,9 +36,9 @@ export function CompetitorDemoVisual() {
|
|||
<motion.div
|
||||
className="p-4 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 relative overflow-hidden shadow-xl"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? '#ef4444' : '#27272a',
|
||||
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px rgba(239, 68, 68, 0.2)'
|
||||
? '0 0 20px hsl(var(--burgundy) / 0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.5)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
|
|
@ -49,7 +49,7 @@ export function CompetitorDemoVisual() {
|
|||
initial={{ x: '-100%', skewX: -20 }}
|
||||
animate={{ x: '200%' }}
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-red-500/10 to-transparent"
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-[hsl(var(--burgundy))]/10 to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -67,7 +67,7 @@ export function CompetitorDemoVisual() {
|
|||
className="text-3xl font-bold"
|
||||
animate={{
|
||||
textDecoration: phase === 1 ? 'line-through' : 'none',
|
||||
color: phase === 1 ? '#ef4444' : '#f4f4f5'
|
||||
color: phase === 1 ? 'hsl(var(--burgundy))' : '#f4f4f5'
|
||||
}}
|
||||
>
|
||||
$99
|
||||
|
|
@ -84,14 +84,14 @@ export function CompetitorDemoVisual() {
|
|||
transition={{ delay: 0.1, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-3 mt-1"
|
||||
>
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-red-500/10">
|
||||
<ArrowDown className="h-4 w-4 text-red-500" strokeWidth={3} />
|
||||
<div className="flex items-center justify-center h-6 w-6 rounded-full bg-[hsl(var(--burgundy))]/10">
|
||||
<ArrowDown className="h-4 w-4 text-[hsl(var(--burgundy))]" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-5xl font-extrabold text-[#ff0000] tracking-tight">
|
||||
<span className="text-5xl font-extrabold text-[hsl(var(--burgundy))] tracking-tight">
|
||||
$79
|
||||
</span>
|
||||
<span className="text-sm font-medium text-red-500">/month</span>
|
||||
<span className="text-sm font-medium text-[hsl(var(--burgundy))]">/month</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -102,9 +102,9 @@ export function CompetitorDemoVisual() {
|
|||
initial={{ opacity: 0, scale: 0.8, rotate: -3 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.3, type: 'spring' }}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-500/10 border border-red-500/20"
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/20"
|
||||
>
|
||||
<span className="text-[10px] font-extrabold text-red-500 uppercase tracking-wider">
|
||||
<span className="text-[10px] font-extrabold text-[hsl(var(--burgundy))] uppercase tracking-wider">
|
||||
Save $240/year
|
||||
</span>
|
||||
</motion.div>
|
||||
|
|
@ -119,17 +119,17 @@ export function CompetitorDemoVisual() {
|
|||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Bell className="h-3 w-3 text-red-500" />
|
||||
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
<motion.span
|
||||
animate={{ scale: [1, 1.3, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-red-500"
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-semibold text-red-500">
|
||||
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
|
||||
Alert sent to your team
|
||||
</span>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export function HeroSection() {
|
|||
custom={4}
|
||||
className="w-full max-w-lg"
|
||||
>
|
||||
<WaitlistForm />
|
||||
<WaitlistForm id="waitlist-form" />
|
||||
</motion.div>
|
||||
|
||||
{/* Trust Signals */}
|
||||
|
|
@ -136,7 +136,7 @@ export function HeroSection() {
|
|||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span>Early access bonus</span>
|
||||
<span>Early access</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
@ -765,7 +765,7 @@ export function FinalCTA() {
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
|
||||
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
|
||||
<span>Early access</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ export function LiveSerpPreview() {
|
|||
<Button
|
||||
variant="outline"
|
||||
className="border-[hsl(var(--primary))] text-[hsl(var(--primary))] hover:bg-[hsl(var(--primary))]/10"
|
||||
onClick={() => document.getElementById('waitlist-form')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
onClick={() => document.getElementById('hero')?.scrollIntoView({ behavior: 'smooth' })}
|
||||
>
|
||||
Get notified on changes
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ export function PolicyDemoVisual() {
|
|||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? '#ef4444' : '#27272a',
|
||||
borderColor: phase === 1 ? 'hsl(var(--burgundy))' : '#27272a',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px rgba(239, 68, 68, 0.2)'
|
||||
? '0 0 20px hsl(var(--burgundy) / 0.2)'
|
||||
: '0 1px 3px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
|
|
@ -63,10 +63,10 @@ export function PolicyDemoVisual() {
|
|||
>
|
||||
<motion.p
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
|
||||
paddingLeft: phase === 1 ? '4px' : '0px',
|
||||
paddingRight: phase === 1 ? '4px' : '0px',
|
||||
color: phase === 1 ? '#ef4444' : 'inherit',
|
||||
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit',
|
||||
fontWeight: phase === 1 ? 600 : 400
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
|
|
@ -91,7 +91,7 @@ export function PolicyDemoVisual() {
|
|||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-red-500 rounded-full origin-left"
|
||||
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
|
@ -114,7 +114,7 @@ export function PolicyDemoVisual() {
|
|||
+18 words
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
|
||||
<span className="w-2 h-2 rounded bg-[hsl(var(--burgundy))]/20 border border-[hsl(var(--burgundy))]" />
|
||||
-7 words
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -128,16 +128,16 @@ export function PolicyDemoVisual() {
|
|||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/30"
|
||||
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-red-500 text-white">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--burgundy))] text-white">
|
||||
<Check className="h-3 w-3" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[9px] font-bold text-red-500">
|
||||
<div className="text-[9px] font-bold text-[hsl(var(--burgundy))]">
|
||||
Audit trail saved
|
||||
</div>
|
||||
<div className="text-[8px] text-red-500/80">
|
||||
<div className="text-[8px] text-[hsl(var(--burgundy))]/80">
|
||||
Snapshot archived for compliance
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ export function SEODemoVisual() {
|
|||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950"
|
||||
animate={{
|
||||
borderColor: phase === 0 ? '#27272a' : '#ef4444',
|
||||
borderColor: phase === 0 ? '#27272a' : 'hsl(var(--burgundy))',
|
||||
boxShadow: phase === 0
|
||||
? '0 1px 3px rgba(0,0,0,0.2)'
|
||||
: '0 0 20px rgba(239, 68, 68, 0.2)'
|
||||
: '0 0 20px hsl(var(--burgundy) / 0.2)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
|
|
@ -86,8 +86,8 @@ export function SEODemoVisual() {
|
|||
>
|
||||
<motion.span
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'rgba(239, 68, 68, 0.1)' : 'transparent',
|
||||
color: phase === 1 ? '#ef4444' : 'inherit'
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.1)' : 'transparent',
|
||||
color: phase === 1 ? 'hsl(var(--burgundy))' : 'inherit'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-block rounded px-0.5"
|
||||
|
|
@ -100,7 +100,7 @@ export function SEODemoVisual() {
|
|||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-red-500 text-[8px] font-bold text-white"
|
||||
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
|
||||
>
|
||||
Changed
|
||||
</motion.span>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { useState } from 'react'
|
|||
import { Check, ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function WaitlistForm() {
|
||||
interface WaitlistFormProps {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function WaitlistForm({ id }: WaitlistFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
|
@ -160,7 +164,7 @@ export function WaitlistForm() {
|
|||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
|
||||
🎉 Early access: 50% off for 6 months
|
||||
🎉 Early access
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
@ -170,65 +174,69 @@ export function WaitlistForm() {
|
|||
|
||||
return (
|
||||
<motion.form
|
||||
id={id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
className="flex-1 relative"
|
||||
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="Enter your email"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
className="flex-1 relative"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="Enter your email"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${error
|
||||
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
|
||||
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Message - Visibility Improved */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="text-red-600 bg-red-50 px-4 py-2 rounded-lg text-sm font-medium border border-red-100 flex items-center gap-2"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500 flex-shrink-0" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</Button>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Trust Signals Below Form */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Globe } from 'lucide-react'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background py-12 text-sm">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="relative h-8 w-8">
|
||||
<Image src="/logo.png" alt="Alertify Logo" fill className="object-contain" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">Alertify</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-xs mb-6">
|
||||
The modern platform for uptime monitoring, change detection, and performance tracking.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Social icons placeholders */}
|
||||
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/#features" className="hover:text-primary transition-colors">Features</Link></li>
|
||||
<li><Link href="/#use-cases" className="hover:text-primary transition-colors">Use Cases</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/blog" className="hover:text-primary transition-colors">Blog</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="/privacy" className="hover:text-primary transition-colors">Privacy</Link></li>
|
||||
<li><Link href="/admin" className="hover:text-primary transition-colors opacity-50 text-xs">Admin</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
||||
<p>© 2026 Alertify. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
|
@ -86,8 +86,15 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
|
|||
},
|
||||
})
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Default to stored user plan from localStorage if API fails or is loading
|
||||
const getStoredPlan = () => {
|
||||
if (!mounted) return 'free'
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
|
|
@ -98,8 +105,8 @@ export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
|
|||
}
|
||||
|
||||
// Capitalize plan name
|
||||
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
|
||||
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
|
||||
const currentPlan = settingsData?.plan || getStoredPlan() || 'free'
|
||||
const planName = currentPlan.charAt(0).toUpperCase() + currentPlan.slice(1);
|
||||
|
||||
// Determine badge color
|
||||
const getBadgeVariant = (plan: string) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Sparkline } from '@/components/sparkline'
|
||||
|
||||
interface Props {
|
||||
monitorId: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export function SEORankingCard({ monitorId, keywords }: Props) {
|
||||
const { data: rankings, isLoading } = useQuery({
|
||||
queryKey: ['rankings', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.rankings(monitorId)
|
||||
return response // { history: [], latest: [] }
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-6 flex justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { latest = [], history = [] } = rankings || {}
|
||||
|
||||
// Group history by keyword for sparklines
|
||||
const historyByKeyword = (history as any[]).reduce((acc, item) => {
|
||||
if (!acc[item.keyword]) acc[item.keyword] = []
|
||||
acc[item.keyword].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, any[]>)
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{keywords.map(keyword => {
|
||||
const latestRank = latest.find((r: any) => r.keyword === keyword)
|
||||
const keywordHistory = historyByKeyword[keyword] || []
|
||||
// Sort history by date asc for sparkline
|
||||
const rankHistory = keywordHistory
|
||||
.sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((item: any) => item.rank || 101) // Use 101 for unranked
|
||||
|
||||
return (
|
||||
<Card key={keyword} className="overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex justify-between items-start">
|
||||
<span className="truncate pr-2" title={keyword}>{keyword}</span>
|
||||
{latestRank?.rank ? (
|
||||
<Badge variant={latestRank.rank <= 3 ? 'success' : latestRank.rank <= 10 ? 'default' : 'secondary'}>
|
||||
#{latestRank.rank}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
Not found
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xs text-muted-foreground mb-3">
|
||||
{latestRank?.urlFound ? (
|
||||
<a href={latestRank.urlFound} target="_blank" rel="noopener noreferrer" className="hover:underline truncate block">
|
||||
{new URL(latestRank.urlFound).pathname}
|
||||
</a>
|
||||
) : (
|
||||
<span>Not in top 100</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rankHistory.length > 1 && (
|
||||
<div className="h-10 w-full mt-2">
|
||||
{/* Simple visualization if Sparkline component accepts array */}
|
||||
<Sparkline
|
||||
data={rankHistory}
|
||||
color={latestRank?.rank ? "#8b5cf6" : "#cbd5e1"}
|
||||
height={40}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-[10px] text-muted-foreground text-right">
|
||||
Last checked: {latestRank ? new Date(latestRank.createdAt).toLocaleDateString() : 'Never'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -91,8 +91,8 @@ export const monitorAPI = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
check: async (id: string, type: 'all' | 'content' | 'seo' = 'all') => {
|
||||
const response = await api.post(`/monitors/${id}/check`, { type });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
|
@ -103,6 +103,13 @@ export const monitorAPI = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
rankings: async (id: string, limit = 100) => {
|
||||
const response = await api.get(`/monitors/${id}/rankings`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ export interface Monitor {
|
|||
name: string
|
||||
frequency: number
|
||||
status: 'active' | 'paused' | 'error'
|
||||
elementSelector?: string
|
||||
ignoreRules?: { type: 'css' | 'regex' | 'text', value: string }[]
|
||||
keywordRules?: { keyword: string, type: 'appears' | 'disappears' | 'count', threshold?: number, caseSensitive?: boolean }[]
|
||||
seoKeywords?: string[]
|
||||
seoInterval?: 'daily' | '2d' | 'weekly' | 'monthly' | 'off'
|
||||
lastSeoCheckAt?: string
|
||||
last_checked_at?: string
|
||||
last_changed_at?: string
|
||||
consecutive_errors: number
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -15,8 +15,10 @@
|
|||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"framer-motion": "^12.27.0",
|
||||
"jimp": "^1.6.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"next": "14.0.4",
|
||||
"posthog-js": "^1.331.1",
|
||||
"react": "^18.2.0",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -0,0 +1,34 @@
|
|||
const JimpModule = require('jimp');
|
||||
console.log('Jimp exports:', Object.keys(JimpModule));
|
||||
const Jimp = JimpModule.Jimp || JimpModule;
|
||||
const path = require('path');
|
||||
|
||||
async function removeBackground(inputPath, outputPath) {
|
||||
try {
|
||||
const image = await Jimp.read(inputPath);
|
||||
|
||||
image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) {
|
||||
const red = this.bitmap.data[idx + 0];
|
||||
const green = this.bitmap.data[idx + 1];
|
||||
const blue = this.bitmap.data[idx + 2];
|
||||
|
||||
// If white (or close to white), make transparent
|
||||
if (red > 240 && green > 240 && blue > 240) {
|
||||
this.bitmap.data[idx + 3] = 0;
|
||||
}
|
||||
});
|
||||
|
||||
image.write(outputPath, (err) => {
|
||||
if (err) {
|
||||
console.error('Error writing file:', err);
|
||||
} else {
|
||||
console.log('Successfully removed background');
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const logoPath = path.join(__dirname, 'public', 'logo.png');
|
||||
removeBackground(logoPath, logoPath);
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from PIL import Image
|
||||
|
||||
def remove_background(input_path, output_path):
|
||||
try:
|
||||
img = Image.open(input_path)
|
||||
img = img.convert("RGBA")
|
||||
datas = img.getdata()
|
||||
|
||||
newData = []
|
||||
for item in datas:
|
||||
# Change all white (also shades of whites)
|
||||
# to transparent
|
||||
if item[0] > 240 and item[1] > 240 and item[2] > 240:
|
||||
newData.append((255, 255, 255, 0))
|
||||
else:
|
||||
newData.append(item)
|
||||
|
||||
img.putdata(newData)
|
||||
img.save(output_path, "PNG")
|
||||
print("Successfully removed background")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
remove_background("c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png", "c:\\Users\\timo\\Documents\\Websites\\website-monitor\\frontend\\public\\logo.png")
|
||||
Loading…
Reference in New Issue