diff --git a/backend/package-lock.json b/backend/package-lock.json index fc0b633..6a28507 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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" } diff --git a/backend/package.json b/backend/package.json index 36c613f..52ee380 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/scripts/verify-waitlist.ts b/backend/scripts/verify-waitlist.ts new file mode 100644 index 0000000..8be9b39 --- /dev/null +++ b/backend/scripts/verify-waitlist.ts @@ -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(); diff --git a/backend/src/db/fix_schema.ts b/backend/src/db/fix_schema.ts new file mode 100644 index 0000000..ff9c3a1 --- /dev/null +++ b/backend/src/db/fix_schema.ts @@ -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(); diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index ba857d2..f6c728a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -13,7 +13,7 @@ function toCamelCase(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(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(row)); }, }, + + rankings: { + async create(data: { + monitorId: string; + keyword: string; + rank: number | null; + urlFound: string | null; + }): Promise { + 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(result.rows[0]); + }, + + async findLatestByMonitorId(monitorId: string, limit = 50): Promise { + // 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(row)); + }, + + async findHistoryByMonitorId(monitorId: string, limit = 100): Promise { + 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(row)); + } + }, }; export default db; diff --git a/backend/src/db/migrations/006_add_seo_scheduling.sql b/backend/src/db/migrations/006_add_seo_scheduling.sql new file mode 100644 index 0000000..93e9d1e --- /dev/null +++ b/backend/src/db/migrations/006_add_seo_scheduling.sql @@ -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; diff --git a/backend/src/db/run_migration_006.ts b/backend/src/db/run_migration_006.ts new file mode 100644 index 0000000..1069c73 --- /dev/null +++ b/backend/src/db/run_migration_006.ts @@ -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(); diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index e2ba602..ece0068 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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; diff --git a/backend/src/routes/monitors.ts b/backend/src/routes/monitors.ts index 45015c8..4e2917a 100644 --- a/backend/src/routes/monitors.ts +++ b/backend/src/routes/monitors.ts @@ -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 => { 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 => { 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 => { + 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 => { try { diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index fcc8be7..9fca10b 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -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 = ` - + - Dynamic Test Page + + CloudScale SaaS - Infrastructure for Growth -

Website Monitor Test

- -
${statusMessage}
- -
-

Current Time: ${timeString}

-

Random Value: ${randomValue}

-

This page content flips every second to simulate a real website change.

-
-

New Feature Update

-

We have deployed a new importance scoring update!

+
+

CloudScale v2.4 Updated

+

Reliable infrastructure that scales with your business needs.

+

Current Update: ${now.toLocaleTimeString()}

+
+ +
+

Simple, Transparent Pricing

+
+
+

Basic

+
$${basicPrice}/mo
+
    +
  • 5 Projects
  • +
  • Basic Analytics
  • +
  • Community Support
  • +
+
+ +
+

Enterprise

+
$${enterprisePrice}/mo
+
    +
  • Everything in Pro
  • +
  • Custom SLAs
  • +
  • Dedicated Account Manager
  • +
  • White-label Branding
  • +
+
+ +
+

From Our Blog

+
+ ${blogPosts.map(p => ` +
+
By ${p.author} • ${p.date}
+

${p.title}

+

Discover how the latest trends in technology are shaping the future of digital products...

+
+ `).join('')} +
+
+ + `; diff --git a/backend/src/routes/tools.ts b/backend/src/routes/tools.ts index a656a19..3cac7b5 100644 --- a/backend/src/routes/tools.ts +++ b/backend/src/routes/tools.ts @@ -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; diff --git a/backend/src/routes/waitlist.ts b/backend/src/routes/waitlist.ts index 88ddb4f..881d92c 100644 --- a/backend/src/routes/waitlist.ts +++ b/backend/src/routes/waitlist.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { pool } from '../db'; +import { query } from '../db'; import { z } from 'zod'; const router = Router(); @@ -17,16 +17,16 @@ router.post('/', async (req, res) => { const data = waitlistSchema.parse(req.body); // Check if email already exists - const existing = await pool.query( + const existing = await query( 'SELECT id FROM waitlist_leads WHERE email = $1', [data.email.toLowerCase()] ); if (existing.rows.length > 0) { // Already on waitlist - return success anyway (don't reveal they're already signed up) - const countResult = await pool.query('SELECT COUNT(*) FROM waitlist_leads'); + const countResult = await query('SELECT COUNT(*) FROM waitlist_leads'); const position = parseInt(countResult.rows[0].count, 10); - + return res.json({ success: true, message: 'You\'re on the list!', @@ -36,13 +36,13 @@ router.post('/', async (req, res) => { } // Insert new lead - await pool.query( + await query( 'INSERT INTO waitlist_leads (email, source, referrer) VALUES ($1, $2, $3)', [data.email.toLowerCase(), data.source, data.referrer || null] ); // Get current position (total count) - const countResult = await pool.query('SELECT COUNT(*) FROM waitlist_leads'); + const countResult = await query('SELECT COUNT(*) FROM waitlist_leads'); const position = parseInt(countResult.rows[0].count, 10); console.log(`✅ Waitlist signup: ${data.email} (Position #${position})`); @@ -73,12 +73,12 @@ router.post('/', async (req, res) => { // GET /api/waitlist/count - Get current waitlist count (public) router.get('/count', async (_req, res) => { try { - const result = await pool.query('SELECT COUNT(*) FROM waitlist_leads'); + const result = await query('SELECT COUNT(*) FROM waitlist_leads'); const count = parseInt(result.rows[0].count, 10); - + // Add a base number to make it look more impressive at launch const displayCount = count + 430; // Starting with "430+ waiting" - + res.json({ success: true, count: displayCount, @@ -92,4 +92,40 @@ router.get('/count', async (_req, res) => { } }); +// GET /api/waitlist/admin - Get waitlist leads (Admin only) +router.get('/admin', async (req, res) => { + try { + const adminPassword = process.env.ADMIN_PASSWORD; + const providedPassword = req.headers['x-admin-password']; + + if (!adminPassword || providedPassword !== adminPassword) { + return res.status(401).json({ + success: false, + message: 'Unauthorized', + }); + } + + // Get stats + const countResult = await query('SELECT COUNT(*) FROM waitlist_leads'); + const total = parseInt(countResult.rows[0].count, 10); + + // Get leads + const leadsResult = await query( + 'SELECT * FROM waitlist_leads ORDER BY created_at DESC LIMIT 100' + ); + + res.json({ + success: true, + total, + leads: leadsResult.rows, + }); + } catch (error) { + console.error('Waitlist admin error:', error); + res.status(500).json({ + success: false, + message: 'Server error', + }); + } +}); + export default router; diff --git a/backend/src/services/monitor.ts b/backend/src/services/monitor.ts index bd7ef4b..20a2a7d 100644 --- a/backend/src/services/monitor.ts +++ b/backend/src/services/monitor.ts @@ -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 { - 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 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 | 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 | 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); diff --git a/backend/src/services/seo.ts b/backend/src/services/seo.ts new file mode 100644 index 0000000..fea1ed0 --- /dev/null +++ b/backend/src/services/seo.ts @@ -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 { + 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 + }); + } +} diff --git a/backend/src/services/summarizer.ts b/backend/src/services/summarizer.ts index fdab797..fa7e22d 100644 --- a/backend/src/services/summarizer.ts +++ b/backend/src/services/summarizer.ts @@ -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 { 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 diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 411c8bd..24dd47d 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -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; } diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..98f28db --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -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 ( +
+ +
+ + + Back to Website + +

Admin Access

+

Enter password to view waitlist

+
+ +
+
+
+ + 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 + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ ) + } + + // DASHBOARD + return ( +
+
+ {/* Header */} +
+
+

Waitlist Dashboard

+

Real-time stats and signups

+
+
+ + +
+
+ + {/* Stats Cards */} +
+ +

Total Signups

+
+ {data?.total || 0} +
+
+ + +

Latest Signup

+
+ {data?.leads[0]?.email || 'N/A'} +
+
+ {data?.leads[0] ? new Date(data.leads[0].created_at).toLocaleString() : '-'} +
+
+
+ + {/* Listings Table */} + +
+

Recent Signups

+
+
+ + + + + + + + + + {data?.leads.map((lead) => ( + + + + + + ))} + {data?.leads.length === 0 && ( + + + + )} + +
EmailSourceDate
+ + {lead.email} + + {lead.source} + {lead.referrer && via {new URL(lead.referrer).hostname}} + + {new Date(lead.created_at).toLocaleString()} +
+ No signups yet. +
+
+
+
+
+ ) +} diff --git a/frontend/app/blog/page.tsx b/frontend/app/blog/page.tsx new file mode 100644 index 0000000..6e203dc --- /dev/null +++ b/frontend/app/blog/page.tsx @@ -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 ( +
+
+
+
+ + + Back to Home + +

Blog

+

+ Latest updates, guides, and insights from the Alertify team. +

+
+ +
+ {/* Placeholder for empty state */} +
+

No posts published yet. Stay tuned!

+
+
+
+
+
+
+ ) +} diff --git a/frontend/app/icon.png b/frontend/app/icon.png new file mode 100644 index 0000000..1458970 Binary files /dev/null and b/frontend/app/icon.png differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 876e52c..303be71 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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 ( - {children} - + + {children} + + + ) diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 4067703..2413234 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -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() {
-
- - - - +
+ Alertify Logo
Welcome back - Sign in to your Website Monitor account + Sign in to your Alertify account diff --git a/frontend/app/monitors/[id]/page.tsx b/frontend/app/monitors/[id]/page.tsx index dbd1b5c..599ce1a 100644 --- a/frontend/app/monitors/[id]/page.tsx +++ b/frontend/app/monitors/[id]/page.tsx @@ -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 ( @@ -66,6 +106,40 @@ export default function MonitorHistoryPage() {
{monitor && (
+ + {monitor.seoKeywords && monitor.seoKeywords.length > 0 && ( + + )}
+ {/* SEO Rankings */} + {monitor && monitor.seoKeywords && monitor.seoKeywords.length > 0 && ( +
+
+ + + +

SEO Keyword Performance

+
+ +
+ )} + {/* History List */}

Check History

@@ -181,7 +268,7 @@ export default function MonitorHistoryPage() { {snapshot.summary && (

Summary

-

{snapshot.summary}

+

{snapshot.summary}

)}
diff --git a/frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx b/frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx index 20b1358..006a0ef 100644 --- a/frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx +++ b/frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx @@ -158,7 +158,7 @@ export default function SnapshotDetailsPage() { {snapshot.summary && (

Change Summary

-

{snapshot.summary}

+

{snapshot.summary}

)} diff --git a/frontend/app/monitors/page.tsx b/frontend/app/monitors/page.tsx index 7cabc14..cae9d48 100644 --- a/frontend/app/monitors/page.tsx +++ b/frontend/app/monitors/page.tsx @@ -87,6 +87,7 @@ export default function MonitorsPage() { const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan() const [showAddForm, setShowAddForm] = useState(false) const [checkingId, setCheckingId] = useState(null) + const [checkingSeoId, setCheckingSeoId] = useState(null) const [editingId, setEditingId] = useState(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() { )}
+ {/* SEO Keywords Section */} +
+
+
+

SEO Tracking

+

Track Google ranking for specific keywords

+
+
+ { + const updated = [...newMonitor.seoKeywords] + updated[index] = e.target.value + setNewMonitor({ ...newMonitor, seoKeywords: updated }) + }} + placeholder="e.g. best coffee in austin" + className="flex-1" + /> + +
+ ))} +
+ )} +
+
{/* Stats Row */} + {/* SEO Status */} + {monitor.seoInterval && monitor.seoInterval !== 'off' && ( +
+
+

{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}

+

SEO Check

+
+
+ {monitor.lastSeoCheckAt ? ( + <> +

+ {new Date(monitor.lastSeoCheckAt).toLocaleDateString()} +

+

Last SEO

+ + ) : ( + <> +

-

+

Last SEO

+ + )} +
+
+ )} +

{monitor.frequency}m

Frequency

- {monitor.last_changed_at ? ( + {monitor.lastChangedAt ? ( <>

- {new Date(monitor.last_changed_at).toLocaleDateString()} + {new Date(monitor.lastChangedAt).toLocaleDateString()}

Last Change

@@ -737,9 +866,9 @@ export default function MonitorsPage() {
{/* Last Checked */} - {monitor.last_checked_at ? ( + {monitor.lastCheckedAt ? (

- Last checked: {new Date(monitor.last_checked_at).toLocaleString()} + Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}

) : (

@@ -747,9 +876,26 @@ export default function MonitorsPage() {

)} + {/* SEO Rankings */} + {monitor.latestRankings && monitor.latestRankings.length > 0 && ( +
+

Top Rankings

+
+ {monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => ( +
+ {r.keyword} + + #{r.rank || '100+'} + +
+ ))} +
+
+ )} + {/* Change Summary */} {monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && ( -

+

"{monitor.recentSnapshots[0].summary}"

)} @@ -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'} + {monitor.seoKeywords && monitor.seoKeywords.length > 0 && ( + + )}
@@ -898,12 +1056,25 @@ export default function MonitorsPage() { + {monitor.seoKeywords && monitor.seoKeywords.length > 0 && ( + + )} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ecf5461..668294d 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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() {
-
- +
+ Alertify Logo
- MonitorTool + Alertify
) } diff --git a/frontend/app/privacy/page.tsx b/frontend/app/privacy/page.tsx new file mode 100644 index 0000000..d18608a --- /dev/null +++ b/frontend/app/privacy/page.tsx @@ -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 ( +
+
+
+
+ + + Back to Home + +

Privacy Policy

+

Last updated: {new Date().toLocaleDateString()}

+
+ +
+

1. Introduction

+

+ 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. +

+ +

2. Data We Collect

+

+ We may collect, use, store and transfer different kinds of personal data about you which we have grouped together follows: +

+
    +
  • Identity Data: includes email address.
  • +
  • Technical Data: includes internet protocol (IP) address, browser type and version, time zone setting and location.
  • +
  • Usage Data: includes information about how you use our website and services.
  • +
+ +

3. How We Use Your Data

+

+ 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: +

+
    +
  • To provide the service you signed up for (Waitlist, Monitoring).
  • +
  • To manage our relationship with you.
  • +
  • To improve our website, products/services, marketing and customer relationships.
  • +
+ +

4. Contact Us

+

+ If you have any questions about this privacy policy or our privacy practices, please contact us at: +

+
+

Alertify Support

+

Email: support@qrmaster.net

+
+
+
+
+
+
+ ) +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx index c541ebc..a4f7fe9 100644 --- a/frontend/app/register/page.tsx +++ b/frontend/app/register/page.tsx @@ -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() {
-
- - - +
+ Alertify Logo
Create account diff --git a/frontend/components/analytics/PostHogPageView.tsx b/frontend/components/analytics/PostHogPageView.tsx new file mode 100644 index 0000000..b926993 --- /dev/null +++ b/frontend/components/analytics/PostHogPageView.tsx @@ -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 ( + + + + ) +} diff --git a/frontend/components/analytics/PostHogProvider.tsx b/frontend/components/analytics/PostHogProvider.tsx new file mode 100644 index 0000000..523b1c5 --- /dev/null +++ b/frontend/components/analytics/PostHogProvider.tsx @@ -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 + + {children} + +} diff --git a/frontend/components/compliance/CookieBanner.tsx b/frontend/components/compliance/CookieBanner.tsx new file mode 100644 index 0000000..04bc270 --- /dev/null +++ b/frontend/components/compliance/CookieBanner.tsx @@ -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 ( + + {show && ( + +
+
+
+ +
+
+

We value your privacy

+

+ We use cookies to enhance your browsing experience and analyze our traffic. By clicking "Accept", you consent to our use of cookies. + Read our Privacy Policy. +

+
+ + +
+
+
+
+
+ )} +
+ ) +} diff --git a/frontend/components/landing/CompetitorDemoVisual.tsx b/frontend/components/landing/CompetitorDemoVisual.tsx index 184ce00..51f45a3 100644 --- a/frontend/components/landing/CompetitorDemoVisual.tsx +++ b/frontend/components/landing/CompetitorDemoVisual.tsx @@ -36,9 +36,9 @@ export function CompetitorDemoVisual() { )} @@ -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" > -
- +
+
- + $79 - /month + /month
)} @@ -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" > - + Save $240/year @@ -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" >
- +
- + Alert sent to your team diff --git a/frontend/components/landing/LandingSections.tsx b/frontend/components/landing/LandingSections.tsx index b51c70e..b73fec9 100644 --- a/frontend/components/landing/LandingSections.tsx +++ b/frontend/components/landing/LandingSections.tsx @@ -115,7 +115,7 @@ export function HeroSection() { custom={4} className="w-full max-w-lg" > - + {/* Trust Signals */} @@ -136,7 +136,7 @@ export function HeroSection() {
- Early access bonus + Early access
@@ -765,7 +765,7 @@ export function FinalCTA() { >
- Early access: 50% off for 6 months + Early access
diff --git a/frontend/components/landing/LiveSerpPreview.tsx b/frontend/components/landing/LiveSerpPreview.tsx index 4216aed..ed251d7 100644 --- a/frontend/components/landing/LiveSerpPreview.tsx +++ b/frontend/components/landing/LiveSerpPreview.tsx @@ -161,7 +161,7 @@ export function LiveSerpPreview() {
@@ -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" > -
+
-
+
Audit trail saved
-
+
Snapshot archived for compliance
diff --git a/frontend/components/landing/SEODemoVisual.tsx b/frontend/components/landing/SEODemoVisual.tsx index c788d95..c7d3472 100644 --- a/frontend/components/landing/SEODemoVisual.tsx +++ b/frontend/components/landing/SEODemoVisual.tsx @@ -59,10 +59,10 @@ export function SEODemoVisual() { @@ -86,8 +86,8 @@ export function SEODemoVisual() { > Changed diff --git a/frontend/components/landing/WaitlistForm.tsx b/frontend/components/landing/WaitlistForm.tsx index 9bb638c..6325c64 100644 --- a/frontend/components/landing/WaitlistForm.tsx +++ b/frontend/components/landing/WaitlistForm.tsx @@ -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" > - 🎉 Early access: 50% off for 6 months + 🎉 Early access @@ -170,65 +174,69 @@ export function WaitlistForm() { return ( -
- {/* Email Input */} - - { - 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 +
+
+ {/* Email Input */} + + { + 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`} - /> - - {error && ( - - {error} - - )} - - + } disabled:opacity-50 disabled:cursor-not-allowed`} + /> + - {/* Submit Button */} - +
+ + {/* Error Message - Visibility Improved */} + + {error && ( + +
+ {error} + )} - +
{/* Trust Signals Below Form */} diff --git a/frontend/components/layout/Footer.tsx b/frontend/components/layout/Footer.tsx new file mode 100644 index 0000000..7a2a01e --- /dev/null +++ b/frontend/components/layout/Footer.tsx @@ -0,0 +1,65 @@ +import Link from 'next/link' +import Image from 'next/image' +import { Globe } from 'lucide-react' + +export function Footer() { + return ( +
+
+
+
+
+
+ Alertify Logo +
+ Alertify +
+

+ The modern platform for uptime monitoring, change detection, and performance tracking. +

+
+ {/* Social icons placeholders */} +
+ +
+
+
+ +
+

Product

+
    +
  • Features
  • +
  • Use Cases
  • +
+
+ +
+

Company

+
    +
  • Blog
  • +
+
+ +
+

Legal

+
    +
  • Privacy
  • +
  • Admin
  • +
+
+
+ +
+

© 2026 Alertify. All rights reserved.

+
+ + + + + System Operational +
+
+
+
+ ) +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 3cdcd13..32f19e8 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -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) => { diff --git a/frontend/components/seo-ranking-card.tsx b/frontend/components/seo-ranking-card.tsx new file mode 100644 index 0000000..86b3e9e --- /dev/null +++ b/frontend/components/seo-ranking-card.tsx @@ -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 ( + + +
+ + + ) + } + + 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) + + return ( +
+ {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 ( + + + + {keyword} + {latestRank?.rank ? ( + + #{latestRank.rank} + + ) : ( + + Not found + + )} + + + +
+ {latestRank?.urlFound ? ( + + {new URL(latestRank.urlFound).pathname} + + ) : ( + Not in top 100 + )} +
+ + {rankHistory.length > 1 && ( +
+ {/* Simple visualization if Sparkline component accepts array */} + +
+ )} + +
+ Last checked: {latestRank ? new Date(latestRank.createdAt).toLocaleDateString() : 'Never'} +
+
+
+ ) + })} +
+ ) +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2da76c1..7b0bdac 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index a09fbc6..a21baa5 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e215a98..9e3b4f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,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", @@ -416,6 +418,418 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@jimp/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-1.6.0.tgz", + "integrity": "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==", + "license": "MIT", + "dependencies": { + "@jimp/file-ops": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "await-to-js": "^3.0.0", + "exif-parser": "^0.1.12", + "file-type": "^16.0.0", + "mime": "3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/diff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/diff/-/diff-1.6.0.tgz", + "integrity": "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw==", + "license": "MIT", + "dependencies": { + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "pixelmatch": "^5.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/file-ops": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/file-ops/-/file-ops-1.6.0.tgz", + "integrity": "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-bmp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-bmp/-/js-bmp-1.6.0.tgz", + "integrity": "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "bmp-ts": "^1.0.9" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-gif": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-gif/-/js-gif-1.6.0.tgz", + "integrity": "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "gifwrap": "^0.10.1", + "omggif": "^1.0.10" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-jpeg": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-jpeg/-/js-jpeg-1.6.0.tgz", + "integrity": "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "jpeg-js": "^0.4.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-png": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-png/-/js-png-1.6.0.tgz", + "integrity": "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "pngjs": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/js-tiff": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/js-tiff/-/js-tiff-1.6.0.tgz", + "integrity": "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "utif2": "^4.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-1.6.0.tgz", + "integrity": "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-1.6.0.tgz", + "integrity": "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-1.6.0.tgz", + "integrity": "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-1.6.0.tgz", + "integrity": "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "tinycolor2": "^1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-1.6.0.tgz", + "integrity": "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-1.6.0.tgz", + "integrity": "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-1.6.0.tgz", + "integrity": "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-1.6.0.tgz", + "integrity": "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-1.6.0.tgz", + "integrity": "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-1.6.0.tgz", + "integrity": "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-1.6.0.tgz", + "integrity": "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-hash": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-hash/-/plugin-hash-1.6.0.tgz", + "integrity": "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "any-base": "^1.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-1.6.0.tgz", + "integrity": "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-1.6.0.tgz", + "integrity": "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/types": "1.6.0", + "parse-bmfont-ascii": "^1.0.6", + "parse-bmfont-binary": "^1.0.6", + "parse-bmfont-xml": "^1.1.6", + "simple-xml-to-json": "^1.2.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-quantize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-quantize/-/plugin-quantize-1.6.0.tgz", + "integrity": "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-1.6.0.tgz", + "integrity": "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/types": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-1.6.0.tgz", + "integrity": "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-1.6.0.tgz", + "integrity": "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/types": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-1.6.0.tgz", + "integrity": "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@jimp/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==", + "license": "MIT", + "dependencies": { + "@jimp/types": "1.6.0", + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -672,6 +1086,332 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.208.0.tgz", + "integrity": "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-exporter-base": "0.208.0", + "@opentelemetry/otlp-transformer": "0.208.0", + "@opentelemetry/sdk-logs": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.208.0.tgz", + "integrity": "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/otlp-transformer": "0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.208.0.tgz", + "integrity": "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/sdk-logs": "0.208.0", + "@opentelemetry/sdk-metrics": "2.2.0", + "@opentelemetry/sdk-trace-base": "2.2.0", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.4.0.tgz", + "integrity": "sha512-RWvGLj2lMDZd7M/5tjkI/2VHMpXebLgPKvBUd9LRasEWR2xAynDwEYZuLvY9P2NGG73HF07jbbgWX2C9oavcQg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.4.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.4.0.tgz", + "integrity": "sha512-KtcyFHssTn5ZgDu6SXmUznS80OFs/wN7y6MyFRRcKU6TOw8hNcGxKvt8hsdaLJfhzUszNSjURetq5Qpkad14Gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.208.0.tgz", + "integrity": "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", + "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", + "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/resources": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/resources": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", + "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.39.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", + "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@posthog/core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.12.0.tgz", + "integrity": "sha512-slDU/sl+kY2L48x6vIMQfa5kYM6eYCgPI9HV19fjhYyj5xRAVN6bGo+8DjrAjOnoN8xchc7ARmkHDYyxb1z1YA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, + "node_modules/@posthog/types": { + "version": "1.331.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.331.1.tgz", + "integrity": "sha512-x7eYaN3oJhvg1MF7QHuUqt/vsPSyw6PFHJufQzJvfTleZey0jFL5LGzWtCQPALNMI/ULWf0HWL8S2WgZLc1e1Q==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -721,6 +1461,12 @@ "react": "^18 || ^19" } }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -743,7 +1489,6 @@ "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -768,6 +1513,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -783,6 +1529,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", @@ -1193,12 +1946,25 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1259,6 +2025,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1550,6 +2322,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-to-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/await-to-js/-/await-to-js-3.0.0.tgz", + "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", @@ -1603,6 +2384,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -1626,6 +2427,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bmp-ts": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bmp-ts/-/bmp-ts-1.0.9.tgz", + "integrity": "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1670,6 +2477,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1684,6 +2492,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -1926,6 +2758,17 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -1946,7 +2789,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2165,6 +3007,15 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2404,6 +3255,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", @@ -2572,6 +3424,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -2844,6 +3697,29 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2905,6 +3781,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2918,6 +3800,23 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-type": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", + "integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==", + "license": "MIT", + "dependencies": { + "readable-web-to-node-stream": "^3.0.0", + "strtok3": "^6.2.4", + "token-types": "^4.1.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3209,6 +4108,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gifwrap": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", + "integrity": "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==", + "license": "MIT", + "dependencies": { + "image-q": "^4.0.0", + "omggif": "^1.0.10" + } + }, "node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -3420,6 +4329,26 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3430,6 +4359,21 @@ "node": ">= 4" } }, + "node_modules/image-q": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/image-q/-/image-q-4.0.0.tgz", + "integrity": "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==", + "license": "MIT", + "dependencies": { + "@types/node": "16.9.1" + } + }, + "node_modules/image-q/node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3931,7 +4875,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -3952,16 +4895,61 @@ "node": ">= 0.4" } }, + "node_modules/jimp": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jimp/-/jimp-1.6.0.tgz", + "integrity": "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==", + "license": "MIT", + "dependencies": { + "@jimp/core": "1.6.0", + "@jimp/diff": "1.6.0", + "@jimp/js-bmp": "1.6.0", + "@jimp/js-gif": "1.6.0", + "@jimp/js-jpeg": "1.6.0", + "@jimp/js-png": "1.6.0", + "@jimp/js-tiff": "1.6.0", + "@jimp/plugin-blit": "1.6.0", + "@jimp/plugin-blur": "1.6.0", + "@jimp/plugin-circle": "1.6.0", + "@jimp/plugin-color": "1.6.0", + "@jimp/plugin-contain": "1.6.0", + "@jimp/plugin-cover": "1.6.0", + "@jimp/plugin-crop": "1.6.0", + "@jimp/plugin-displace": "1.6.0", + "@jimp/plugin-dither": "1.6.0", + "@jimp/plugin-fisheye": "1.6.0", + "@jimp/plugin-flip": "1.6.0", + "@jimp/plugin-hash": "1.6.0", + "@jimp/plugin-mask": "1.6.0", + "@jimp/plugin-print": "1.6.0", + "@jimp/plugin-quantize": "1.6.0", + "@jimp/plugin-resize": "1.6.0", + "@jimp/plugin-rotate": "1.6.0", + "@jimp/plugin-threshold": "1.6.0", + "@jimp/types": "1.6.0", + "@jimp/utils": "1.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "license": "BSD-3-Clause" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4135,6 +5123,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4195,6 +5189,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4538,6 +5544,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4616,6 +5628,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4628,6 +5646,28 @@ "node": ">=6" } }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4670,7 +5710,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4691,6 +5730,19 @@ "node": ">=8" } }, + "node_modules/peek-readable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", + "integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4730,6 +5782,36 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4760,6 +5842,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4860,6 +5943,37 @@ "dev": true, "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.331.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.331.1.tgz", + "integrity": "sha512-r79H/LNioEB1TZk9Zla/kd1T+Ukon8J1Hbozgqv4MP7koQP1cTbhw/L41cGjy89lMPImSaLN9QFLprku0cFRLw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.208.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-logs": "^0.208.0", + "@posthog/core": "1.12.0", + "@posthog/types": "1.331.1", + "core-js": "^3.38.1", + "dompurify": "^3.3.1", + "fflate": "^0.4.8", + "preact": "^10.28.0", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^4.2.4" + } + }, + "node_modules/preact": { + "version": "10.28.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", + "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4870,6 +5984,15 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4881,6 +6004,30 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4897,6 +6044,12 @@ "node": ">=6" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4923,6 +6076,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4955,6 +6109,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4979,6 +6134,38 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz", + "integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^4.7.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5147,6 +6334,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5182,6 +6389,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5257,7 +6473,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5270,7 +6485,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5352,6 +6566,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-xml-to-json": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/simple-xml-to-json/-/simple-xml-to-json-1.2.3.tgz", + "integrity": "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA==", + "license": "MIT", + "engines": { + "node": ">=20.12.2" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5419,6 +6642,15 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5568,6 +6800,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strtok3": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz", + "integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "peek-readable": "^4.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -5736,24 +6985,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5784,6 +7015,12 @@ "node": ">=0.8" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5825,6 +7062,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5845,6 +7083,23 @@ "node": ">=8.0" } }, + "node_modules/token-types": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz", + "integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -5994,6 +7249,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6025,7 +7281,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -6104,6 +7359,15 @@ "punycode": "^2.1.0" } }, + "node_modules/utif2": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/utif2/-/utif2-4.1.0.tgz", + "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.11" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6124,11 +7388,16 @@ "node": ">=10.13.0" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6246,6 +7515,34 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c31673..4e6a5df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000..5deeafa Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/remove_bg.js b/frontend/remove_bg.js new file mode 100644 index 0000000..12531fb --- /dev/null +++ b/frontend/remove_bg.js @@ -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); diff --git a/frontend/remove_bg.py b/frontend/remove_bg.py new file mode 100644 index 0000000..7da02a8 --- /dev/null +++ b/frontend/remove_bg.py @@ -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")