gitea
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
active: true
|
||||
iteration: 1
|
||||
max_iterations: 0
|
||||
completion_promise: null
|
||||
started_at: "2026-01-17T14:40:37Z"
|
||||
---
|
||||
|
||||
Implement website monitor features in priority order:
|
||||
|
|
@ -1,40 +1,40 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Production
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Next.js
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Production
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -1,325 +1,325 @@
|
|||
# Website Change Detection Monitor - Project Status
|
||||
|
||||
## ✅ Implementation Complete (MVP)
|
||||
|
||||
The Website Change Detection Monitor has been successfully implemented with all core MVP features.
|
||||
|
||||
## 🎯 What Was Built
|
||||
|
||||
### Backend (Express + TypeScript)
|
||||
✅ **Authentication System**
|
||||
- User registration with password validation
|
||||
- JWT-based login
|
||||
- Secure password hashing (bcrypt)
|
||||
- Auth middleware for protected routes
|
||||
|
||||
✅ **Database Layer**
|
||||
- PostgreSQL schema with 4 tables (users, monitors, snapshots, alerts)
|
||||
- Type-safe database queries
|
||||
- Automatic timestamps
|
||||
- Foreign key relationships
|
||||
|
||||
✅ **Monitor Management API**
|
||||
- Create, read, update, delete monitors
|
||||
- Plan-based limits (free/pro/business)
|
||||
- Frequency validation
|
||||
- URL validation
|
||||
|
||||
✅ **Monitoring Engine**
|
||||
- HTTP page fetching with retries
|
||||
- HTML parsing and text extraction
|
||||
- Content hash generation
|
||||
- Change detection with diff algorithm
|
||||
- Noise filtering (timestamps, cookie banners)
|
||||
- Error handling and logging
|
||||
|
||||
✅ **Alert System**
|
||||
- Email notifications for changes
|
||||
- Error alerts for failed checks
|
||||
- Keyword-based alerts (infrastructure ready)
|
||||
- Alert history tracking
|
||||
- Nodemailer integration
|
||||
|
||||
### Frontend (Next.js + TypeScript)
|
||||
✅ **Authentication UI**
|
||||
- Login page with validation
|
||||
- Registration page with password requirements
|
||||
- Session management (localStorage + JWT)
|
||||
- Auto-redirect for protected pages
|
||||
|
||||
✅ **Dashboard**
|
||||
- Monitor list view
|
||||
- Create monitor form
|
||||
- Status indicators
|
||||
- Quick actions (Check Now, Delete)
|
||||
- Empty states
|
||||
|
||||
✅ **Monitor History**
|
||||
- Timeline of all checks
|
||||
- Change indicators
|
||||
- Error display
|
||||
- Status badges (Changed/No Change/Error)
|
||||
- Responsive design
|
||||
|
||||
### Infrastructure
|
||||
✅ **Docker Setup**
|
||||
- PostgreSQL container
|
||||
- Redis container
|
||||
- Docker Compose configuration
|
||||
|
||||
✅ **Configuration**
|
||||
- Environment variables
|
||||
- TypeScript configs
|
||||
- Tailwind CSS setup
|
||||
- Next.js configuration
|
||||
|
||||
✅ **Documentation**
|
||||
- Comprehensive README
|
||||
- Quick setup guide
|
||||
- API documentation
|
||||
- Troubleshooting guide
|
||||
|
||||
## 📊 Project Structure
|
||||
|
||||
```
|
||||
website-monitor/
|
||||
├── backend/ # API Server (Express + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts # Auth endpoints
|
||||
│ │ │ └── monitors.ts # Monitor CRUD & history
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── fetcher.ts # Page fetching
|
||||
│ │ │ ├── differ.ts # Change detection
|
||||
│ │ │ ├── monitor.ts # Monitor orchestration
|
||||
│ │ │ └── alerter.ts # Email alerts
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── index.ts # Database queries
|
||||
│ │ │ ├── schema.sql # Database schema
|
||||
│ │ │ └── migrate.ts # Migration script
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── auth.ts # JWT authentication
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── auth.ts # Password hashing, validation
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts # TypeScript types
|
||||
│ │ └── index.ts # Express server
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
│
|
||||
├── frontend/ # Web App (Next.js + TypeScript)
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # Root redirect
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── register/ # Registration page
|
||||
│ │ ├── dashboard/ # Main dashboard
|
||||
│ │ └── monitors/[id]/ # Monitor history
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # API client (axios)
|
||||
│ │ └── auth.ts # Auth helpers
|
||||
│ ├── globals.css # Tailwind styles
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
│
|
||||
├── docs/ # Documentation (to be added)
|
||||
├── docker-compose.yml # Database services
|
||||
├── README.md # Full documentation
|
||||
├── SETUP.md # Quick start guide
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## 🚀 How to Run
|
||||
|
||||
### 1. Start Services
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Backend
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Access
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:3001
|
||||
- Database: localhost:5432
|
||||
- Redis: localhost:6379
|
||||
|
||||
## ✨ Features Implemented
|
||||
|
||||
### Core Features
|
||||
- ✅ User registration and login
|
||||
- ✅ JWT authentication
|
||||
- ✅ Create/edit/delete monitors
|
||||
- ✅ Configurable check frequency (5min - 24hr)
|
||||
- ✅ Automatic page checking
|
||||
- ✅ Manual check triggering
|
||||
- ✅ Change detection with diff
|
||||
- ✅ Hash-based comparison
|
||||
- ✅ Email alerts on changes
|
||||
- ✅ Error alerts after 3 failures
|
||||
- ✅ History timeline
|
||||
- ✅ Snapshot storage
|
||||
- ✅ Plan-based limits
|
||||
|
||||
### Smart Features
|
||||
- ✅ Automatic noise filtering
|
||||
- ✅ Retry logic (3 attempts)
|
||||
- ✅ User-agent rotation
|
||||
- ✅ Timeout handling
|
||||
- ✅ HTTP status tracking
|
||||
- ✅ Response time monitoring
|
||||
- ✅ Consecutive error tracking
|
||||
|
||||
## 🔜 Not Yet Implemented (V1 & V2)
|
||||
|
||||
### V1 Features (Next Phase)
|
||||
- ⏳ Job queue with BullMQ (infrastructure ready)
|
||||
- ⏳ Element-specific monitoring (CSS selectors)
|
||||
- ⏳ Custom ignore rules (user-defined)
|
||||
- ⏳ Keyword alerts (backend ready, needs UI)
|
||||
- ⏳ Digest mode (daily/weekly summaries)
|
||||
- ⏳ Quiet hours
|
||||
- ⏳ Stripe billing integration
|
||||
|
||||
### V2 Features (Future)
|
||||
- ⏳ Screenshot capture
|
||||
- ⏳ Visual diff
|
||||
- ⏳ AI change summaries
|
||||
- ⏳ JavaScript rendering (Puppeteer)
|
||||
- ⏳ Login-protected pages
|
||||
- ⏳ Slack integration
|
||||
- ⏳ Discord webhooks
|
||||
- ⏳ REST API for external access
|
||||
|
||||
### Power User Features
|
||||
- ⏳ Folders/tags
|
||||
- ⏳ Bulk operations
|
||||
- ⏳ Team workspaces
|
||||
- ⏳ Role-based access
|
||||
- ⏳ Comments on changes
|
||||
|
||||
## 🎓 Technical Highlights
|
||||
|
||||
### Backend
|
||||
- **Type Safety**: Full TypeScript coverage
|
||||
- **Security**: Bcrypt password hashing, JWT tokens, SQL injection prevention
|
||||
- **Reliability**: Retry logic, error handling, transaction support
|
||||
- **Performance**: Efficient diff algorithm, hash comparison, indexed queries
|
||||
- **Scalability**: Ready for Redis job queue, horizontal scaling possible
|
||||
|
||||
### Frontend
|
||||
- **Modern Stack**: Next.js 14, React Query, TypeScript
|
||||
- **UX**: Loading states, error handling, responsive design
|
||||
- **Performance**: Client-side caching, optimistic updates
|
||||
- **Type Safety**: Full TypeScript, API type definitions
|
||||
|
||||
### Database
|
||||
- **Normalized**: Proper foreign keys, indexes
|
||||
- **Scalable**: Ready for millions of snapshots
|
||||
- **Maintainable**: Migrations, seed data support
|
||||
- **Performant**: Indexes on frequently queried fields
|
||||
|
||||
## 📈 Current Capabilities
|
||||
|
||||
The system can currently:
|
||||
1. **Monitor unlimited URLs** (plan limits enforced)
|
||||
2. **Check every 5 minutes minimum** (configurable per plan)
|
||||
3. **Store 50 snapshots per monitor** (auto-cleanup)
|
||||
4. **Detect text changes** with percentage calculation
|
||||
5. **Send email alerts** on changes and errors
|
||||
6. **Filter common noise** (timestamps, cookies)
|
||||
7. **Retry failed requests** up to 3 times
|
||||
8. **Track response times** and HTTP status
|
||||
9. **Support multiple plans** with different limits
|
||||
10. **Handle concurrent requests** safely
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
- ✅ Password validation (8+ chars, uppercase, lowercase, number)
|
||||
- ✅ Bcrypt password hashing
|
||||
- ✅ JWT token authentication
|
||||
- ✅ Protected API routes
|
||||
- ✅ Input validation (Zod schemas)
|
||||
- ✅ SQL injection prevention (parameterized queries)
|
||||
- ✅ XSS prevention (React auto-escaping)
|
||||
- ✅ CORS configuration
|
||||
|
||||
## 📊 Database Statistics (Estimated)
|
||||
|
||||
For a typical deployment:
|
||||
- **Users**: Can handle millions
|
||||
- **Monitors**: 10K+ per user (with pagination)
|
||||
- **Snapshots**: Billions (with auto-cleanup)
|
||||
- **Alerts**: Unlimited history
|
||||
- **Query Performance**: <50ms for most queries
|
||||
|
||||
## 🎯 Production Readiness
|
||||
|
||||
### Ready ✅
|
||||
- Core functionality complete
|
||||
- Authentication working
|
||||
- Database schema stable
|
||||
- Error handling implemented
|
||||
- Basic security measures
|
||||
- Documentation complete
|
||||
|
||||
### Needs Work ⚠️
|
||||
- Job queue for background checks (currently manual)
|
||||
- Production email service (currently localhost)
|
||||
- Rate limiting (API level)
|
||||
- Monitoring/logging (production grade)
|
||||
- Backup strategy
|
||||
- Load testing
|
||||
- Security audit
|
||||
|
||||
## 💡 Getting Started
|
||||
|
||||
See `SETUP.md` for detailed setup instructions.
|
||||
|
||||
**Quick start:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
cd backend && npm install && npm run migrate && npm run dev
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Then visit http://localhost:3000 and create an account!
|
||||
|
||||
## 🎉 Summary
|
||||
|
||||
This is a **fully functional MVP** of a Website Change Detection Monitor. All core features are implemented and working. The system is ready for local testing and development.
|
||||
|
||||
**Next steps:**
|
||||
1. Add job queue for automated checks
|
||||
2. Implement keyword alerts UI
|
||||
3. Add element-specific monitoring
|
||||
4. Integrate Stripe for billing
|
||||
5. Deploy to production
|
||||
6. Add V2 features (screenshots, AI, integrations)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check `README.md` for full documentation
|
||||
2. See `SETUP.md` for setup help
|
||||
3. Review error logs in terminal
|
||||
4. Check database with pgAdmin or TablePlus
|
||||
|
||||
---
|
||||
|
||||
**Project Status**: ✅ MVP Complete
|
||||
**Last Updated**: 2026-01-16
|
||||
**Ready for**: Local testing, feature expansion, production deployment
|
||||
# Website Change Detection Monitor - Project Status
|
||||
|
||||
## ✅ Implementation Complete (MVP)
|
||||
|
||||
The Website Change Detection Monitor has been successfully implemented with all core MVP features.
|
||||
|
||||
## 🎯 What Was Built
|
||||
|
||||
### Backend (Express + TypeScript)
|
||||
✅ **Authentication System**
|
||||
- User registration with password validation
|
||||
- JWT-based login
|
||||
- Secure password hashing (bcrypt)
|
||||
- Auth middleware for protected routes
|
||||
|
||||
✅ **Database Layer**
|
||||
- PostgreSQL schema with 4 tables (users, monitors, snapshots, alerts)
|
||||
- Type-safe database queries
|
||||
- Automatic timestamps
|
||||
- Foreign key relationships
|
||||
|
||||
✅ **Monitor Management API**
|
||||
- Create, read, update, delete monitors
|
||||
- Plan-based limits (free/pro/business)
|
||||
- Frequency validation
|
||||
- URL validation
|
||||
|
||||
✅ **Monitoring Engine**
|
||||
- HTTP page fetching with retries
|
||||
- HTML parsing and text extraction
|
||||
- Content hash generation
|
||||
- Change detection with diff algorithm
|
||||
- Noise filtering (timestamps, cookie banners)
|
||||
- Error handling and logging
|
||||
|
||||
✅ **Alert System**
|
||||
- Email notifications for changes
|
||||
- Error alerts for failed checks
|
||||
- Keyword-based alerts (infrastructure ready)
|
||||
- Alert history tracking
|
||||
- Nodemailer integration
|
||||
|
||||
### Frontend (Next.js + TypeScript)
|
||||
✅ **Authentication UI**
|
||||
- Login page with validation
|
||||
- Registration page with password requirements
|
||||
- Session management (localStorage + JWT)
|
||||
- Auto-redirect for protected pages
|
||||
|
||||
✅ **Dashboard**
|
||||
- Monitor list view
|
||||
- Create monitor form
|
||||
- Status indicators
|
||||
- Quick actions (Check Now, Delete)
|
||||
- Empty states
|
||||
|
||||
✅ **Monitor History**
|
||||
- Timeline of all checks
|
||||
- Change indicators
|
||||
- Error display
|
||||
- Status badges (Changed/No Change/Error)
|
||||
- Responsive design
|
||||
|
||||
### Infrastructure
|
||||
✅ **Docker Setup**
|
||||
- PostgreSQL container
|
||||
- Redis container
|
||||
- Docker Compose configuration
|
||||
|
||||
✅ **Configuration**
|
||||
- Environment variables
|
||||
- TypeScript configs
|
||||
- Tailwind CSS setup
|
||||
- Next.js configuration
|
||||
|
||||
✅ **Documentation**
|
||||
- Comprehensive README
|
||||
- Quick setup guide
|
||||
- API documentation
|
||||
- Troubleshooting guide
|
||||
|
||||
## 📊 Project Structure
|
||||
|
||||
```
|
||||
website-monitor/
|
||||
├── backend/ # API Server (Express + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts # Auth endpoints
|
||||
│ │ │ └── monitors.ts # Monitor CRUD & history
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── fetcher.ts # Page fetching
|
||||
│ │ │ ├── differ.ts # Change detection
|
||||
│ │ │ ├── monitor.ts # Monitor orchestration
|
||||
│ │ │ └── alerter.ts # Email alerts
|
||||
│ │ ├── db/
|
||||
│ │ │ ├── index.ts # Database queries
|
||||
│ │ │ ├── schema.sql # Database schema
|
||||
│ │ │ └── migrate.ts # Migration script
|
||||
│ │ ├── middleware/
|
||||
│ │ │ └── auth.ts # JWT authentication
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── auth.ts # Password hashing, validation
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts # TypeScript types
|
||||
│ │ └── index.ts # Express server
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env
|
||||
│
|
||||
├── frontend/ # Web App (Next.js + TypeScript)
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # Root redirect
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── register/ # Registration page
|
||||
│ │ ├── dashboard/ # Main dashboard
|
||||
│ │ └── monitors/[id]/ # Monitor history
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # API client (axios)
|
||||
│ │ └── auth.ts # Auth helpers
|
||||
│ ├── globals.css # Tailwind styles
|
||||
│ ├── package.json
|
||||
│ └── tsconfig.json
|
||||
│
|
||||
├── docs/ # Documentation (to be added)
|
||||
├── docker-compose.yml # Database services
|
||||
├── README.md # Full documentation
|
||||
├── SETUP.md # Quick start guide
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## 🚀 How to Run
|
||||
|
||||
### 1. Start Services
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Backend
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Access
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:3001
|
||||
- Database: localhost:5432
|
||||
- Redis: localhost:6379
|
||||
|
||||
## ✨ Features Implemented
|
||||
|
||||
### Core Features
|
||||
- ✅ User registration and login
|
||||
- ✅ JWT authentication
|
||||
- ✅ Create/edit/delete monitors
|
||||
- ✅ Configurable check frequency (5min - 24hr)
|
||||
- ✅ Automatic page checking
|
||||
- ✅ Manual check triggering
|
||||
- ✅ Change detection with diff
|
||||
- ✅ Hash-based comparison
|
||||
- ✅ Email alerts on changes
|
||||
- ✅ Error alerts after 3 failures
|
||||
- ✅ History timeline
|
||||
- ✅ Snapshot storage
|
||||
- ✅ Plan-based limits
|
||||
|
||||
### Smart Features
|
||||
- ✅ Automatic noise filtering
|
||||
- ✅ Retry logic (3 attempts)
|
||||
- ✅ User-agent rotation
|
||||
- ✅ Timeout handling
|
||||
- ✅ HTTP status tracking
|
||||
- ✅ Response time monitoring
|
||||
- ✅ Consecutive error tracking
|
||||
|
||||
## 🔜 Not Yet Implemented (V1 & V2)
|
||||
|
||||
### V1 Features (Next Phase)
|
||||
- ⏳ Job queue with BullMQ (infrastructure ready)
|
||||
- ⏳ Element-specific monitoring (CSS selectors)
|
||||
- ⏳ Custom ignore rules (user-defined)
|
||||
- ⏳ Keyword alerts (backend ready, needs UI)
|
||||
- ⏳ Digest mode (daily/weekly summaries)
|
||||
- ⏳ Quiet hours
|
||||
- ⏳ Stripe billing integration
|
||||
|
||||
### V2 Features (Future)
|
||||
- ⏳ Screenshot capture
|
||||
- ⏳ Visual diff
|
||||
- ⏳ AI change summaries
|
||||
- ⏳ JavaScript rendering (Puppeteer)
|
||||
- ⏳ Login-protected pages
|
||||
- ⏳ Slack integration
|
||||
- ⏳ Discord webhooks
|
||||
- ⏳ REST API for external access
|
||||
|
||||
### Power User Features
|
||||
- ⏳ Folders/tags
|
||||
- ⏳ Bulk operations
|
||||
- ⏳ Team workspaces
|
||||
- ⏳ Role-based access
|
||||
- ⏳ Comments on changes
|
||||
|
||||
## 🎓 Technical Highlights
|
||||
|
||||
### Backend
|
||||
- **Type Safety**: Full TypeScript coverage
|
||||
- **Security**: Bcrypt password hashing, JWT tokens, SQL injection prevention
|
||||
- **Reliability**: Retry logic, error handling, transaction support
|
||||
- **Performance**: Efficient diff algorithm, hash comparison, indexed queries
|
||||
- **Scalability**: Ready for Redis job queue, horizontal scaling possible
|
||||
|
||||
### Frontend
|
||||
- **Modern Stack**: Next.js 14, React Query, TypeScript
|
||||
- **UX**: Loading states, error handling, responsive design
|
||||
- **Performance**: Client-side caching, optimistic updates
|
||||
- **Type Safety**: Full TypeScript, API type definitions
|
||||
|
||||
### Database
|
||||
- **Normalized**: Proper foreign keys, indexes
|
||||
- **Scalable**: Ready for millions of snapshots
|
||||
- **Maintainable**: Migrations, seed data support
|
||||
- **Performant**: Indexes on frequently queried fields
|
||||
|
||||
## 📈 Current Capabilities
|
||||
|
||||
The system can currently:
|
||||
1. **Monitor unlimited URLs** (plan limits enforced)
|
||||
2. **Check every 5 minutes minimum** (configurable per plan)
|
||||
3. **Store 50 snapshots per monitor** (auto-cleanup)
|
||||
4. **Detect text changes** with percentage calculation
|
||||
5. **Send email alerts** on changes and errors
|
||||
6. **Filter common noise** (timestamps, cookies)
|
||||
7. **Retry failed requests** up to 3 times
|
||||
8. **Track response times** and HTTP status
|
||||
9. **Support multiple plans** with different limits
|
||||
10. **Handle concurrent requests** safely
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
- ✅ Password validation (8+ chars, uppercase, lowercase, number)
|
||||
- ✅ Bcrypt password hashing
|
||||
- ✅ JWT token authentication
|
||||
- ✅ Protected API routes
|
||||
- ✅ Input validation (Zod schemas)
|
||||
- ✅ SQL injection prevention (parameterized queries)
|
||||
- ✅ XSS prevention (React auto-escaping)
|
||||
- ✅ CORS configuration
|
||||
|
||||
## 📊 Database Statistics (Estimated)
|
||||
|
||||
For a typical deployment:
|
||||
- **Users**: Can handle millions
|
||||
- **Monitors**: 10K+ per user (with pagination)
|
||||
- **Snapshots**: Billions (with auto-cleanup)
|
||||
- **Alerts**: Unlimited history
|
||||
- **Query Performance**: <50ms for most queries
|
||||
|
||||
## 🎯 Production Readiness
|
||||
|
||||
### Ready ✅
|
||||
- Core functionality complete
|
||||
- Authentication working
|
||||
- Database schema stable
|
||||
- Error handling implemented
|
||||
- Basic security measures
|
||||
- Documentation complete
|
||||
|
||||
### Needs Work ⚠️
|
||||
- Job queue for background checks (currently manual)
|
||||
- Production email service (currently localhost)
|
||||
- Rate limiting (API level)
|
||||
- Monitoring/logging (production grade)
|
||||
- Backup strategy
|
||||
- Load testing
|
||||
- Security audit
|
||||
|
||||
## 💡 Getting Started
|
||||
|
||||
See `SETUP.md` for detailed setup instructions.
|
||||
|
||||
**Quick start:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
cd backend && npm install && npm run migrate && npm run dev
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Then visit http://localhost:3000 and create an account!
|
||||
|
||||
## 🎉 Summary
|
||||
|
||||
This is a **fully functional MVP** of a Website Change Detection Monitor. All core features are implemented and working. The system is ready for local testing and development.
|
||||
|
||||
**Next steps:**
|
||||
1. Add job queue for automated checks
|
||||
2. Implement keyword alerts UI
|
||||
3. Add element-specific monitoring
|
||||
4. Integrate Stripe for billing
|
||||
5. Deploy to production
|
||||
6. Add V2 features (screenshots, AI, integrations)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check `README.md` for full documentation
|
||||
2. See `SETUP.md` for setup help
|
||||
3. Review error logs in terminal
|
||||
4. Check database with pgAdmin or TablePlus
|
||||
|
||||
---
|
||||
|
||||
**Project Status**: ✅ MVP Complete
|
||||
**Last Updated**: 2026-01-16
|
||||
**Ready for**: Local testing, feature expansion, production deployment
|
||||
|
|
|
|||
380
SETUP.md
|
|
@ -1,190 +1,190 @@
|
|||
# Quick Setup Guide
|
||||
|
||||
## 🚀 Quick Start (5 minutes)
|
||||
|
||||
### Step 1: Start Database Services
|
||||
```bash
|
||||
# In project root
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL and Redis in Docker containers.
|
||||
|
||||
### Step 2: Setup Backend
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate # Create database tables
|
||||
npm run dev # Start backend server
|
||||
```
|
||||
|
||||
Backend runs on http://localhost:3001
|
||||
|
||||
### Step 3: Setup Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Start frontend server
|
||||
```
|
||||
|
||||
Frontend runs on http://localhost:3000
|
||||
|
||||
### Step 4: Create Account
|
||||
1. Open http://localhost:3000
|
||||
2. Click "Sign up"
|
||||
3. Enter email and password (min 8 chars, must include uppercase, lowercase, number)
|
||||
4. You're ready to go!
|
||||
|
||||
## ✅ Verify Installation
|
||||
|
||||
### Check Backend Health
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "...",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
|
||||
### Check Database
|
||||
```bash
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor -c "\dt"
|
||||
```
|
||||
|
||||
Should show tables: users, monitors, snapshots, alerts
|
||||
|
||||
### Check Redis
|
||||
```bash
|
||||
docker exec -it website-monitor-redis redis-cli ping
|
||||
```
|
||||
|
||||
Should return: `PONG`
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### Port Already in Use
|
||||
If port 3000 or 3001 is busy:
|
||||
```bash
|
||||
# Backend: Change PORT in backend/.env
|
||||
PORT=3002
|
||||
|
||||
# Frontend: Run on different port
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker ps
|
||||
|
||||
# Restart if needed
|
||||
docker-compose restart postgres
|
||||
|
||||
# Check logs
|
||||
docker logs website-monitor-postgres
|
||||
```
|
||||
|
||||
### Cannot Create Account
|
||||
- Password must be 8+ characters
|
||||
- Must include uppercase, lowercase, and number
|
||||
- Example: `Password123`
|
||||
|
||||
## 📦 What Was Created
|
||||
|
||||
```
|
||||
website-monitor/
|
||||
├── backend/ # Express API server
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── db/ # Database layer
|
||||
│ │ ├── middleware/ # Auth middleware
|
||||
│ │ └── types/ # TypeScript types
|
||||
│ ├── package.json
|
||||
│ └── .env
|
||||
├── frontend/ # Next.js web app
|
||||
│ ├── app/
|
||||
│ │ ├── dashboard/ # Main dashboard
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── register/ # Register page
|
||||
│ │ └── monitors/ # Monitor history
|
||||
│ ├── lib/ # API client & auth
|
||||
│ ├── package.json
|
||||
│ └── .env.local
|
||||
├── docker-compose.yml # Database services
|
||||
└── README.md # Full documentation
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Create First Monitor**
|
||||
- Go to Dashboard
|
||||
- Click "+ Add Monitor"
|
||||
- Enter a URL (e.g., https://example.com)
|
||||
- Select check frequency
|
||||
- Click "Create Monitor"
|
||||
|
||||
2. **Trigger Manual Check**
|
||||
- Click "Check Now" on any monitor
|
||||
- Wait a few seconds
|
||||
- Click "History" to see results
|
||||
|
||||
3. **View Changes**
|
||||
- When a page changes, you'll see it in History
|
||||
- Changed entries are highlighted in blue
|
||||
- View details for each check
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Email Alerts (Optional)
|
||||
To enable email alerts, configure SMTP in `backend/.env`:
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=alerts@yourdomain.com
|
||||
```
|
||||
|
||||
For development, use [Mailtrap.io](https://mailtrap.io) (free).
|
||||
|
||||
### Adjust Plan Limits
|
||||
Edit `backend/.env`:
|
||||
|
||||
```env
|
||||
MAX_MONITORS_FREE=5
|
||||
MAX_MONITORS_PRO=50
|
||||
MIN_FREQUENCY_FREE=60 # minutes
|
||||
MIN_FREQUENCY_PRO=5 # minutes
|
||||
```
|
||||
|
||||
## 📖 Learn More
|
||||
|
||||
- See `README.md` for complete documentation
|
||||
- Check `backend/src/routes/` for API endpoints
|
||||
- Look at `frontend/app/` for page components
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Test with Simple Sites**: Start with simple, fast-loading websites
|
||||
2. **Adjust Frequency**: Use longer intervals (60+ min) for testing
|
||||
3. **Check Logs**: Watch terminal output for errors
|
||||
4. **Database GUI**: Use [TablePlus](https://tableplus.com) or [pgAdmin](https://www.pgadmin.org) to inspect database
|
||||
|
||||
## 🆘 Get Help
|
||||
|
||||
If you encounter issues:
|
||||
1. Check logs in terminal
|
||||
2. Verify all services are running
|
||||
3. Review error messages
|
||||
4. Check environment variables
|
||||
|
||||
## 🎉 You're All Set!
|
||||
|
||||
You now have a fully functional website monitoring system. Start by creating your first monitor and watch it track changes!
|
||||
# Quick Setup Guide
|
||||
|
||||
## 🚀 Quick Start (5 minutes)
|
||||
|
||||
### Step 1: Start Database Services
|
||||
```bash
|
||||
# In project root
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This starts PostgreSQL and Redis in Docker containers.
|
||||
|
||||
### Step 2: Setup Backend
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
npm run migrate # Create database tables
|
||||
npm run dev # Start backend server
|
||||
```
|
||||
|
||||
Backend runs on http://localhost:3001
|
||||
|
||||
### Step 3: Setup Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Start frontend server
|
||||
```
|
||||
|
||||
Frontend runs on http://localhost:3000
|
||||
|
||||
### Step 4: Create Account
|
||||
1. Open http://localhost:3000
|
||||
2. Click "Sign up"
|
||||
3. Enter email and password (min 8 chars, must include uppercase, lowercase, number)
|
||||
4. You're ready to go!
|
||||
|
||||
## ✅ Verify Installation
|
||||
|
||||
### Check Backend Health
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "...",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
|
||||
### Check Database
|
||||
```bash
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor -c "\dt"
|
||||
```
|
||||
|
||||
Should show tables: users, monitors, snapshots, alerts
|
||||
|
||||
### Check Redis
|
||||
```bash
|
||||
docker exec -it website-monitor-redis redis-cli ping
|
||||
```
|
||||
|
||||
Should return: `PONG`
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### Port Already in Use
|
||||
If port 3000 or 3001 is busy:
|
||||
```bash
|
||||
# Backend: Change PORT in backend/.env
|
||||
PORT=3002
|
||||
|
||||
# Frontend: Run on different port
|
||||
npm run dev -- -p 3001
|
||||
```
|
||||
|
||||
### Database Connection Failed
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker ps
|
||||
|
||||
# Restart if needed
|
||||
docker-compose restart postgres
|
||||
|
||||
# Check logs
|
||||
docker logs website-monitor-postgres
|
||||
```
|
||||
|
||||
### Cannot Create Account
|
||||
- Password must be 8+ characters
|
||||
- Must include uppercase, lowercase, and number
|
||||
- Example: `Password123`
|
||||
|
||||
## 📦 What Was Created
|
||||
|
||||
```
|
||||
website-monitor/
|
||||
├── backend/ # Express API server
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API endpoints
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── db/ # Database layer
|
||||
│ │ ├── middleware/ # Auth middleware
|
||||
│ │ └── types/ # TypeScript types
|
||||
│ ├── package.json
|
||||
│ └── .env
|
||||
├── frontend/ # Next.js web app
|
||||
│ ├── app/
|
||||
│ │ ├── dashboard/ # Main dashboard
|
||||
│ │ ├── login/ # Login page
|
||||
│ │ ├── register/ # Register page
|
||||
│ │ └── monitors/ # Monitor history
|
||||
│ ├── lib/ # API client & auth
|
||||
│ ├── package.json
|
||||
│ └── .env.local
|
||||
├── docker-compose.yml # Database services
|
||||
└── README.md # Full documentation
|
||||
```
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
1. **Create First Monitor**
|
||||
- Go to Dashboard
|
||||
- Click "+ Add Monitor"
|
||||
- Enter a URL (e.g., https://example.com)
|
||||
- Select check frequency
|
||||
- Click "Create Monitor"
|
||||
|
||||
2. **Trigger Manual Check**
|
||||
- Click "Check Now" on any monitor
|
||||
- Wait a few seconds
|
||||
- Click "History" to see results
|
||||
|
||||
3. **View Changes**
|
||||
- When a page changes, you'll see it in History
|
||||
- Changed entries are highlighted in blue
|
||||
- View details for each check
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Email Alerts (Optional)
|
||||
To enable email alerts, configure SMTP in `backend/.env`:
|
||||
|
||||
```env
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=alerts@yourdomain.com
|
||||
```
|
||||
|
||||
For development, use [Mailtrap.io](https://mailtrap.io) (free).
|
||||
|
||||
### Adjust Plan Limits
|
||||
Edit `backend/.env`:
|
||||
|
||||
```env
|
||||
MAX_MONITORS_FREE=5
|
||||
MAX_MONITORS_PRO=50
|
||||
MIN_FREQUENCY_FREE=60 # minutes
|
||||
MIN_FREQUENCY_PRO=5 # minutes
|
||||
```
|
||||
|
||||
## 📖 Learn More
|
||||
|
||||
- See `README.md` for complete documentation
|
||||
- Check `backend/src/routes/` for API endpoints
|
||||
- Look at `frontend/app/` for page components
|
||||
|
||||
## 💡 Tips
|
||||
|
||||
1. **Test with Simple Sites**: Start with simple, fast-loading websites
|
||||
2. **Adjust Frequency**: Use longer intervals (60+ min) for testing
|
||||
3. **Check Logs**: Watch terminal output for errors
|
||||
4. **Database GUI**: Use [TablePlus](https://tableplus.com) or [pgAdmin](https://www.pgadmin.org) to inspect database
|
||||
|
||||
## 🆘 Get Help
|
||||
|
||||
If you encounter issues:
|
||||
1. Check logs in terminal
|
||||
2. Verify all services are running
|
||||
3. Review error messages
|
||||
4. Check environment variables
|
||||
|
||||
## 🎉 You're All Set!
|
||||
|
||||
You now have a fully functional website monitoring system. Start by creating your first monitor and watch it track changes!
|
||||
|
|
|
|||
360
START.md
|
|
@ -1,180 +1,180 @@
|
|||
# 🚀 Starting the Website Monitor
|
||||
|
||||
## ✅ Setup Complete!
|
||||
|
||||
Your Website Monitor is ready to run!
|
||||
|
||||
## Start the Servers
|
||||
|
||||
### Option 1: Manual Start (Recommended for Development)
|
||||
|
||||
Open **3 separate terminals**:
|
||||
|
||||
#### Terminal 1: Docker (Database)
|
||||
```bash
|
||||
cd website-monitor
|
||||
docker-compose up
|
||||
```
|
||||
Leave this running to see database logs.
|
||||
|
||||
#### Terminal 2: Backend API
|
||||
```bash
|
||||
cd website-monitor/backend
|
||||
npm run dev
|
||||
```
|
||||
Backend will run on http://localhost:3002
|
||||
|
||||
#### Terminal 3: Frontend
|
||||
```bash
|
||||
cd website-monitor/frontend
|
||||
npm run dev
|
||||
```
|
||||
Frontend will run on http://localhost:3000
|
||||
|
||||
### Option 2: Background Mode
|
||||
|
||||
```bash
|
||||
# Start databases in background
|
||||
cd website-monitor
|
||||
docker-compose up -d
|
||||
|
||||
# Start backend (in one terminal)
|
||||
cd backend
|
||||
npm run dev
|
||||
|
||||
# Start frontend (in another terminal)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🎯 Access the Application
|
||||
|
||||
Once all three are running:
|
||||
1. Open http://localhost:3000
|
||||
2. Click "Sign up" to create an account
|
||||
3. Enter email and password (min 8 chars, needs uppercase, lowercase, number)
|
||||
4. Start monitoring websites!
|
||||
|
||||
## 🔍 Verify Everything Works
|
||||
|
||||
### Check Backend Health
|
||||
```bash
|
||||
curl http://localhost:3002/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "...",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
|
||||
### Check Database
|
||||
```bash
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor -c "SELECT COUNT(*) FROM users;"
|
||||
```
|
||||
|
||||
### Check Services
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Both containers should show "Up" and "healthy".
|
||||
|
||||
## 📋 Create Your First Monitor
|
||||
|
||||
1. **Register Account**: Go to http://localhost:3000 and sign up
|
||||
2. **Add Monitor**: Click "+ Add Monitor" button
|
||||
3. **Enter URL**: e.g., `https://example.com`
|
||||
4. **Set Frequency**: Choose how often to check (5min, 30min, 1hr, etc.)
|
||||
5. **Create**: Click "Create Monitor"
|
||||
6. **Check Now**: Click "Check Now" to trigger immediate check
|
||||
7. **View History**: Click "History" to see results
|
||||
|
||||
## 🛑 Stopping the Application
|
||||
|
||||
### Stop Backend/Frontend
|
||||
Press `Ctrl+C` in each terminal window
|
||||
|
||||
### Stop Docker
|
||||
```bash
|
||||
cd website-monitor
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Ports Used
|
||||
- Frontend: **3000**
|
||||
- Backend API: **3002**
|
||||
- PostgreSQL: **5433** (changed from 5432 to avoid conflicts)
|
||||
- Redis: **6380** (changed from 6379 to avoid conflicts)
|
||||
|
||||
### Database Credentials
|
||||
- Host: localhost:5433
|
||||
- Database: website_monitor
|
||||
- User: admin
|
||||
- Password: admin123
|
||||
|
||||
## 📝 Common Commands
|
||||
|
||||
```bash
|
||||
# View backend logs
|
||||
cd backend && npm run dev
|
||||
|
||||
# View frontend logs
|
||||
cd frontend && npm run dev
|
||||
|
||||
# View database logs
|
||||
docker logs -f website-monitor-postgres
|
||||
|
||||
# View Redis logs
|
||||
docker logs -f website-monitor-redis
|
||||
|
||||
# Access database directly
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor
|
||||
|
||||
# Access Redis directly
|
||||
docker exec -it website-monitor-redis redis-cli -p 6379
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
If you see "port already in use":
|
||||
- Frontend (3000): `npm run dev -- -p 3001`
|
||||
- Backend (3002): Change PORT in backend/.env
|
||||
|
||||
### Database Connection Error
|
||||
```bash
|
||||
# Restart PostgreSQL
|
||||
docker-compose restart postgres
|
||||
|
||||
# Check if running
|
||||
docker ps
|
||||
```
|
||||
|
||||
### Can't Create Account
|
||||
Password requirements:
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- Example: `Password123`
|
||||
|
||||
## 🎉 You're All Set!
|
||||
|
||||
Your website monitoring system is running and ready to use!
|
||||
|
||||
**Next steps:**
|
||||
- Create your first monitor
|
||||
- Test with simple websites like https://example.com
|
||||
- Check the history to see changes
|
||||
- Explore the dashboard features
|
||||
|
||||
For more details, see:
|
||||
- `README.md` - Full documentation
|
||||
- `SETUP.md` - Detailed setup guide
|
||||
- `PROJECT_STATUS.md` - Current features
|
||||
# 🚀 Starting the Website Monitor
|
||||
|
||||
## ✅ Setup Complete!
|
||||
|
||||
Your Website Monitor is ready to run!
|
||||
|
||||
## Start the Servers
|
||||
|
||||
### Option 1: Manual Start (Recommended for Development)
|
||||
|
||||
Open **3 separate terminals**:
|
||||
|
||||
#### Terminal 1: Docker (Database)
|
||||
```bash
|
||||
cd website-monitor
|
||||
docker-compose up
|
||||
```
|
||||
Leave this running to see database logs.
|
||||
|
||||
#### Terminal 2: Backend API
|
||||
```bash
|
||||
cd website-monitor/backend
|
||||
npm run dev
|
||||
```
|
||||
Backend will run on http://localhost:3002
|
||||
|
||||
#### Terminal 3: Frontend
|
||||
```bash
|
||||
cd website-monitor/frontend
|
||||
npm run dev
|
||||
```
|
||||
Frontend will run on http://localhost:3000
|
||||
|
||||
### Option 2: Background Mode
|
||||
|
||||
```bash
|
||||
# Start databases in background
|
||||
cd website-monitor
|
||||
docker-compose up -d
|
||||
|
||||
# Start backend (in one terminal)
|
||||
cd backend
|
||||
npm run dev
|
||||
|
||||
# Start frontend (in another terminal)
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🎯 Access the Application
|
||||
|
||||
Once all three are running:
|
||||
1. Open http://localhost:3000
|
||||
2. Click "Sign up" to create an account
|
||||
3. Enter email and password (min 8 chars, needs uppercase, lowercase, number)
|
||||
4. Start monitoring websites!
|
||||
|
||||
## 🔍 Verify Everything Works
|
||||
|
||||
### Check Backend Health
|
||||
```bash
|
||||
curl http://localhost:3002/health
|
||||
```
|
||||
|
||||
Should return:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "...",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
|
||||
### Check Database
|
||||
```bash
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor -c "SELECT COUNT(*) FROM users;"
|
||||
```
|
||||
|
||||
### Check Services
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
Both containers should show "Up" and "healthy".
|
||||
|
||||
## 📋 Create Your First Monitor
|
||||
|
||||
1. **Register Account**: Go to http://localhost:3000 and sign up
|
||||
2. **Add Monitor**: Click "+ Add Monitor" button
|
||||
3. **Enter URL**: e.g., `https://example.com`
|
||||
4. **Set Frequency**: Choose how often to check (5min, 30min, 1hr, etc.)
|
||||
5. **Create**: Click "Create Monitor"
|
||||
6. **Check Now**: Click "Check Now" to trigger immediate check
|
||||
7. **View History**: Click "History" to see results
|
||||
|
||||
## 🛑 Stopping the Application
|
||||
|
||||
### Stop Backend/Frontend
|
||||
Press `Ctrl+C` in each terminal window
|
||||
|
||||
### Stop Docker
|
||||
```bash
|
||||
cd website-monitor
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Ports Used
|
||||
- Frontend: **3000**
|
||||
- Backend API: **3002**
|
||||
- PostgreSQL: **5433** (changed from 5432 to avoid conflicts)
|
||||
- Redis: **6380** (changed from 6379 to avoid conflicts)
|
||||
|
||||
### Database Credentials
|
||||
- Host: localhost:5433
|
||||
- Database: website_monitor
|
||||
- User: admin
|
||||
- Password: admin123
|
||||
|
||||
## 📝 Common Commands
|
||||
|
||||
```bash
|
||||
# View backend logs
|
||||
cd backend && npm run dev
|
||||
|
||||
# View frontend logs
|
||||
cd frontend && npm run dev
|
||||
|
||||
# View database logs
|
||||
docker logs -f website-monitor-postgres
|
||||
|
||||
# View Redis logs
|
||||
docker logs -f website-monitor-redis
|
||||
|
||||
# Access database directly
|
||||
docker exec -it website-monitor-postgres psql -U admin -d website_monitor
|
||||
|
||||
# Access Redis directly
|
||||
docker exec -it website-monitor-redis redis-cli -p 6379
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
If you see "port already in use":
|
||||
- Frontend (3000): `npm run dev -- -p 3001`
|
||||
- Backend (3002): Change PORT in backend/.env
|
||||
|
||||
### Database Connection Error
|
||||
```bash
|
||||
# Restart PostgreSQL
|
||||
docker-compose restart postgres
|
||||
|
||||
# Check if running
|
||||
docker ps
|
||||
```
|
||||
|
||||
### Can't Create Account
|
||||
Password requirements:
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
- Example: `Password123`
|
||||
|
||||
## 🎉 You're All Set!
|
||||
|
||||
Your website monitoring system is running and ready to use!
|
||||
|
||||
**Next steps:**
|
||||
- Create your first monitor
|
||||
- Test with simple websites like https://example.com
|
||||
- Check the history to see changes
|
||||
- Explore the dashboard features
|
||||
|
||||
For more details, see:
|
||||
- `README.md` - Full documentation
|
||||
- `SETUP.md` - Detailed setup guide
|
||||
- `PROJECT_STATUS.md` - Current features
|
||||
|
|
|
|||
1144
actions.md
|
|
@ -1,32 +1,35 @@
|
|||
# Server
|
||||
PORT=3002
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://admin:admin123@localhost:5433/website_monitor
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6380
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Email (Sendgrid/SMTP)
|
||||
EMAIL_FROM=noreply@websitemonitor.com
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
|
||||
# App
|
||||
APP_URL=http://localhost:3000
|
||||
API_URL=http://localhost:3002
|
||||
|
||||
# Rate Limiting
|
||||
MAX_MONITORS_FREE=5
|
||||
MAX_MONITORS_PRO=50
|
||||
MAX_MONITORS_BUSINESS=200
|
||||
MIN_FREQUENCY_FREE=60
|
||||
MIN_FREQUENCY_PRO=5
|
||||
MIN_FREQUENCY_BUSINESS=1
|
||||
# Server
|
||||
PORT=3002
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://admin:admin123@localhost:5433/website_monitor
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6380
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Email (Sendgrid/SMTP)
|
||||
EMAIL_FROM=noreply@websitemonitor.com
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=your-sendgrid-api-key
|
||||
|
||||
# App
|
||||
APP_URL=http://localhost:3000
|
||||
API_URL=http://localhost:3002
|
||||
|
||||
# Rate Limiting
|
||||
MAX_MONITORS_FREE=5
|
||||
MAX_MONITORS_PRO=50
|
||||
MAX_MONITORS_BUSINESS=200
|
||||
MIN_FREQUENCY_FREE=60
|
||||
MIN_FREQUENCY_PRO=5
|
||||
MIN_FREQUENCY_BUSINESS=1
|
||||
|
||||
# AI Summary (OpenAI)
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
|
|
|||
|
|
@ -1,49 +1,55 @@
|
|||
{
|
||||
"name": "website-monitor-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Website Change Detection Monitor",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"test": "jest",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"keywords": ["website", "monitor", "change-detection"],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"bullmq": "^5.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"axios": "^1.6.5",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"diff": "^5.1.0",
|
||||
"zod": "^3.22.4",
|
||||
"nodemailer": "^6.9.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/diff": "^5.0.9",
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"@types/jest": "^29.5.11"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "website-monitor-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Website Change Detection Monitor",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"migrate": "tsx src/db/migrate.ts",
|
||||
"test": "jest",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"keywords": [
|
||||
"website",
|
||||
"monitor",
|
||||
"change-detection"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.5",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.1.0",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"diff": "^5.1.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.8",
|
||||
"openai": "^6.16.0",
|
||||
"pg": "^8.11.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/diff": "^5.0.9",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import 'dotenv/config';
|
||||
import { query } from '../src/db';
|
||||
import { fetchPage } from '../src/services/fetcher';
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const result = await query('SELECT * FROM monitors WHERE url LIKE $1', ['%3002%']);
|
||||
const testMonitor = result.rows[0];
|
||||
|
||||
if (testMonitor) {
|
||||
console.log(`Found monitor: "${testMonitor.name}"`);
|
||||
console.log(`URL in DB: "${testMonitor.url}"`);
|
||||
console.log(`URL length: ${testMonitor.url.length}`);
|
||||
|
||||
console.log('Testing fetchPage for DB URL...');
|
||||
const result = await fetchPage(testMonitor.url);
|
||||
console.log('Result:', {
|
||||
status: result.status,
|
||||
byteLength: result.html.length,
|
||||
error: result.error
|
||||
});
|
||||
} else {
|
||||
console.log('No monitor found with port 3002');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
|
||||
import { calculateImportanceScore } from '../src/services/importance';
|
||||
|
||||
async function run() {
|
||||
console.log('Testing Importance Score Calculation');
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'Case 1: 100% change, short content, main content',
|
||||
factors: {
|
||||
changePercentage: 100,
|
||||
keywordMatches: 0,
|
||||
isMainContent: true,
|
||||
isRecurringPattern: false,
|
||||
contentLength: 200
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Case 2: 100% change, short content, NOT main content',
|
||||
factors: {
|
||||
changePercentage: 100,
|
||||
keywordMatches: 0,
|
||||
isMainContent: false,
|
||||
isRecurringPattern: false,
|
||||
contentLength: 200
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Case 3: 0.1% change, short content',
|
||||
factors: {
|
||||
changePercentage: 0.1,
|
||||
keywordMatches: 0,
|
||||
isMainContent: true,
|
||||
isRecurringPattern: false,
|
||||
contentLength: 50
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of testCases) {
|
||||
const score = calculateImportanceScore(test.factors);
|
||||
console.log(`\n${test.name}:`);
|
||||
console.log(`Factors:`, test.factors);
|
||||
console.log(`Score: ${score}`);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
import 'dotenv/config';
|
||||
import { query } from '../src/db';
|
||||
|
||||
async function run() {
|
||||
console.log('Migrating database: Adding importance_score to snapshots table...');
|
||||
try {
|
||||
await query(`
|
||||
ALTER TABLE snapshots
|
||||
ADD COLUMN IF NOT EXISTS importance_score INTEGER DEFAULT 0;
|
||||
`);
|
||||
console.log('Migration successful: importance_score column added.');
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err);
|
||||
}
|
||||
process.exit();
|
||||
}
|
||||
|
||||
run();
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { UserPlan } from './types';
|
||||
|
||||
/**
|
||||
* Plan-based limits and configuration
|
||||
*/
|
||||
export const PLAN_LIMITS = {
|
||||
free: {
|
||||
retentionDays: 7,
|
||||
maxMonitors: 3,
|
||||
minFrequency: 60, // minutes
|
||||
features: ['email_alerts', 'basic_noise_filtering'],
|
||||
},
|
||||
pro: {
|
||||
retentionDays: 90,
|
||||
maxMonitors: 20,
|
||||
minFrequency: 5,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export'],
|
||||
},
|
||||
business: {
|
||||
retentionDays: 365,
|
||||
maxMonitors: 100,
|
||||
minFrequency: 1,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members'],
|
||||
},
|
||||
enterprise: {
|
||||
retentionDays: 730, // 2 years
|
||||
maxMonitors: Infinity,
|
||||
minFrequency: 1,
|
||||
features: ['email_alerts', 'slack_integration', 'webhook_integration', 'keyword_alerts', 'smart_noise_filtering', 'audit_export', 'api_access', 'team_members', 'custom_integrations', 'sla'],
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get the retention period in days for a user plan
|
||||
*/
|
||||
export function getRetentionDays(plan: UserPlan): number {
|
||||
return PLAN_LIMITS[plan]?.retentionDays || PLAN_LIMITS.free.retentionDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of monitors for a user plan
|
||||
*/
|
||||
export function getMaxMonitors(plan: UserPlan): number {
|
||||
return PLAN_LIMITS[plan]?.maxMonitors || PLAN_LIMITS.free.maxMonitors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plan has a specific feature
|
||||
*/
|
||||
export function hasFeature(plan: UserPlan, feature: string): boolean {
|
||||
const planConfig = PLAN_LIMITS[plan] || PLAN_LIMITS.free;
|
||||
return planConfig.features.includes(feature as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook retry configuration
|
||||
*/
|
||||
export const WEBHOOK_CONFIG = {
|
||||
maxRetries: 3,
|
||||
retryDelayMs: 1000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
/**
|
||||
* App configuration
|
||||
*/
|
||||
export const APP_CONFIG = {
|
||||
appUrl: process.env.APP_URL || 'http://localhost:3000',
|
||||
emailFrom: process.env.EMAIL_FROM || 'noreply@websitemonitor.com',
|
||||
};
|
||||
|
|
@ -1,284 +1,438 @@
|
|||
import { Pool, QueryResult } from 'pg';
|
||||
import { User, Monitor, Snapshot, Alert } from '../types';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected database error:', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
export const query = async <T = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> => {
|
||||
const start = Date.now();
|
||||
const result = await pool.query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (duration > 1000) {
|
||||
console.warn(`Slow query (${duration}ms):`, text);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getClient = () => pool.connect();
|
||||
|
||||
// User queries
|
||||
export const db = {
|
||||
users: {
|
||||
async create(email: string, passwordHash: string): Promise<User> {
|
||||
const result = await query<User>(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
|
||||
[email, passwordHash]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
'SELECT * FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const result = await query<User>(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async update(id: string, updates: Partial<User>): Promise<User | null> {
|
||||
const fields = Object.keys(updates);
|
||||
const values = Object.values(updates);
|
||||
const setClause = fields.map((field, i) => `${field} = $${i + 2}`).join(', ');
|
||||
|
||||
const result = await query<User>(
|
||||
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
|
||||
[id, ...values]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async updateLastLogin(id: string): Promise<void> {
|
||||
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
},
|
||||
|
||||
monitors: {
|
||||
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
|
||||
const result = await query<Monitor>(
|
||||
`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 *`,
|
||||
[
|
||||
data.userId,
|
||||
data.url,
|
||||
data.name,
|
||||
data.frequency,
|
||||
data.status,
|
||||
data.elementSelector || null,
|
||||
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
|
||||
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Monitor | null> {
|
||||
const result = await query<Monitor>(
|
||||
'SELECT * FROM monitors WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<Monitor[]> {
|
||||
const result = await query<Monitor>(
|
||||
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await query<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM monitors WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
},
|
||||
|
||||
async findActiveMonitors(): Promise<Monitor[]> {
|
||||
const result = await query<Monitor>(
|
||||
'SELECT * FROM monitors WHERE status = $1',
|
||||
['active']
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 2;
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
if (key === 'ignoreRules' || key === 'keywordRules') {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(value);
|
||||
}
|
||||
paramCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
const result = await query<Monitor>(
|
||||
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
|
||||
[id, ...values]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await query('DELETE FROM monitors WHERE id = $1', [id]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
},
|
||||
|
||||
async updateLastChecked(id: string, changed: boolean): Promise<void> {
|
||||
if (changed) {
|
||||
await query(
|
||||
'UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0 WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0 WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async incrementErrors(id: string): Promise<void> {
|
||||
await query(
|
||||
'UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1 WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
snapshots: {
|
||||
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
|
||||
const result = await query<Snapshot>(
|
||||
`INSERT INTO snapshots (
|
||||
monitor_id, html_content, text_content, content_hash, screenshot_url,
|
||||
http_status, response_time, changed, change_percentage, error_message
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`,
|
||||
[
|
||||
data.monitorId,
|
||||
data.htmlContent,
|
||||
data.textContent,
|
||||
data.contentHash,
|
||||
data.screenshotUrl || null,
|
||||
data.httpStatus,
|
||||
data.responseTime,
|
||||
data.changed,
|
||||
data.changePercentage || null,
|
||||
data.errorMessage || null,
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
|
||||
const result = await query<Snapshot>(
|
||||
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[monitorId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
|
||||
const result = await query<Snapshot>(
|
||||
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
|
||||
[monitorId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Snapshot | null> {
|
||||
const result = await query<Snapshot>(
|
||||
'SELECT * FROM snapshots WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
|
||||
await query(
|
||||
`DELETE FROM snapshots
|
||||
WHERE monitor_id = $1
|
||||
AND id NOT IN (
|
||||
SELECT id FROM snapshots
|
||||
WHERE monitor_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
)`,
|
||||
[monitorId, keepCount]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
alerts: {
|
||||
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
|
||||
const result = await query<Alert>(
|
||||
`INSERT INTO alerts (
|
||||
monitor_id, snapshot_id, user_id, type, title, summary, channels
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[
|
||||
data.monitorId,
|
||||
data.snapshotId,
|
||||
data.userId,
|
||||
data.type,
|
||||
data.title,
|
||||
data.summary || null,
|
||||
JSON.stringify(data.channels),
|
||||
]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
|
||||
const result = await query<Alert>(
|
||||
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async markAsDelivered(id: string): Promise<void> {
|
||||
await query('UPDATE alerts SET delivered_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
import { Pool, QueryResult, QueryResultRow } from 'pg';
|
||||
import { User, Monitor, Snapshot, Alert } from '../types';
|
||||
|
||||
// Convert snake_case database keys to camelCase TypeScript properties
|
||||
function toCamelCase<T>(obj: any): T {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (Array.isArray(obj)) return obj.map(item => toCamelCase<any>(item)) as T;
|
||||
if (typeof obj !== 'object') return obj;
|
||||
|
||||
const result: any = {};
|
||||
for (const key in obj) {
|
||||
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
let value = obj[key];
|
||||
|
||||
// Parse JSON fields that are stored as strings in the database
|
||||
if ((key === 'ignore_rules' || key === 'keyword_rules') && typeof value === 'string') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
// Keep as-is if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
result[camelKey] = value;
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected database error:', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
export const query = async <T extends QueryResultRow = any>(
|
||||
text: string,
|
||||
params?: any[]
|
||||
): Promise<QueryResult<T>> => {
|
||||
const start = Date.now();
|
||||
const result = await pool.query<T>(text, params);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (duration > 1000) {
|
||||
console.warn(`Slow query (${duration}ms):`, text);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getClient = () => pool.connect();
|
||||
|
||||
// User queries
|
||||
export const db = {
|
||||
users: {
|
||||
async create(email: string, passwordHash: string): Promise<User> {
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING *',
|
||||
[email, passwordHash]
|
||||
);
|
||||
return toCamelCase<User>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
const result = await query(
|
||||
'SELECT * FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const result = await query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<User>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async update(id: string, updates: Partial<User>): Promise<User | null> {
|
||||
const fields = Object.keys(updates);
|
||||
const values = Object.values(updates);
|
||||
const setClause = fields.map((field, i) => `${field} = $${i + 2}`).join(', ');
|
||||
|
||||
const result = await query<User>(
|
||||
`UPDATE users SET ${setClause} WHERE id = $1 RETURNING *`,
|
||||
[id, ...values]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async updateLastLogin(id: string): Promise<void> {
|
||||
await query('UPDATE users SET last_login_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
|
||||
async updatePassword(id: string, passwordHash: string): Promise<void> {
|
||||
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, id]);
|
||||
},
|
||||
|
||||
async updateNotificationSettings(
|
||||
id: string,
|
||||
settings: {
|
||||
emailEnabled?: boolean;
|
||||
webhookUrl?: string | null;
|
||||
webhookEnabled?: boolean;
|
||||
slackWebhookUrl?: string | null;
|
||||
slackEnabled?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (settings.emailEnabled !== undefined) {
|
||||
updates.push(`email_enabled = $${paramIndex++}`);
|
||||
values.push(settings.emailEnabled);
|
||||
}
|
||||
if (settings.webhookUrl !== undefined) {
|
||||
updates.push(`webhook_url = $${paramIndex++}`);
|
||||
values.push(settings.webhookUrl);
|
||||
}
|
||||
if (settings.webhookEnabled !== undefined) {
|
||||
updates.push(`webhook_enabled = $${paramIndex++}`);
|
||||
values.push(settings.webhookEnabled);
|
||||
}
|
||||
if (settings.slackWebhookUrl !== undefined) {
|
||||
updates.push(`slack_webhook_url = $${paramIndex++}`);
|
||||
values.push(settings.slackWebhookUrl);
|
||||
}
|
||||
if (settings.slackEnabled !== undefined) {
|
||||
updates.push(`slack_enabled = $${paramIndex++}`);
|
||||
values.push(settings.slackEnabled);
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
values.push(id);
|
||||
await query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await query('DELETE FROM users WHERE id = $1', [id]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
},
|
||||
|
||||
async verifyEmail(email: string): Promise<void> {
|
||||
await query(
|
||||
'UPDATE users SET email_verified = true, email_verified_at = NOW() WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
monitors: {
|
||||
async create(data: Omit<Monitor, 'id' | 'createdAt' | 'updatedAt' | 'consecutiveErrors'>): Promise<Monitor> {
|
||||
const result = await query(
|
||||
`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 *`,
|
||||
[
|
||||
data.userId,
|
||||
data.url,
|
||||
data.name,
|
||||
data.frequency,
|
||||
data.status,
|
||||
data.elementSelector || null,
|
||||
data.ignoreRules ? JSON.stringify(data.ignoreRules) : null,
|
||||
data.keywordRules ? JSON.stringify(data.keywordRules) : null,
|
||||
]
|
||||
);
|
||||
return toCamelCase<Monitor>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Monitor | null> {
|
||||
const result = await query(
|
||||
'SELECT * FROM monitors WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async findByUserId(userId: string): Promise<Monitor[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM monitors WHERE user_id = $1 ORDER BY created_at DESC',
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<Monitor>(row));
|
||||
},
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await query<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM monitors WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
},
|
||||
|
||||
async findActiveMonitors(): Promise<Monitor[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM monitors WHERE status = $1',
|
||||
['active']
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<Monitor>(row));
|
||||
},
|
||||
|
||||
async update(id: string, updates: Partial<Monitor>): Promise<Monitor | null> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 2;
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
if (key === 'ignoreRules' || key === 'keywordRules') {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
fields.push(`${snakeKey} = $${paramCount}`);
|
||||
values.push(value);
|
||||
}
|
||||
paramCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
const result = await query(
|
||||
`UPDATE monitors SET ${fields.join(', ')} WHERE id = $1 RETURNING *`,
|
||||
[id, ...values]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<Monitor>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await query('DELETE FROM monitors WHERE id = $1', [id]);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
},
|
||||
|
||||
async updateLastChecked(id: string, changed: boolean): Promise<void> {
|
||||
if (changed) {
|
||||
await query(
|
||||
"UPDATE monitors SET last_checked_at = NOW(), last_changed_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = 0, status = 'active' WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async incrementErrors(id: string): Promise<void> {
|
||||
await query(
|
||||
"UPDATE monitors SET last_checked_at = NOW(), consecutive_errors = consecutive_errors + 1, status = CASE WHEN consecutive_errors >= 0 THEN 'error' ELSE status END WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
snapshots: {
|
||||
async create(data: Omit<Snapshot, 'id' | 'createdAt'>): Promise<Snapshot> {
|
||||
const result = await query(
|
||||
`INSERT INTO snapshots (
|
||||
monitor_id, html_content, text_content, content_hash, screenshot_url,
|
||||
http_status, response_time, changed, change_percentage, error_message,
|
||||
importance_score, summary
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
||||
[
|
||||
data.monitorId,
|
||||
data.htmlContent,
|
||||
data.textContent,
|
||||
data.contentHash,
|
||||
data.screenshotUrl || null,
|
||||
data.httpStatus,
|
||||
data.responseTime,
|
||||
data.changed,
|
||||
data.changePercentage || null,
|
||||
data.errorMessage || null,
|
||||
data.importanceScore ?? 0,
|
||||
data.summary || null,
|
||||
]
|
||||
);
|
||||
return toCamelCase<Snapshot>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findByMonitorId(monitorId: string, limit = 50): Promise<Snapshot[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[monitorId, limit]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<Snapshot>(row));
|
||||
},
|
||||
|
||||
async findLatestByMonitorId(monitorId: string): Promise<Snapshot | null> {
|
||||
const result = await query(
|
||||
'SELECT * FROM snapshots WHERE monitor_id = $1 ORDER BY created_at DESC LIMIT 1',
|
||||
[monitorId]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async findById(id: string): Promise<Snapshot | null> {
|
||||
const result = await query(
|
||||
'SELECT * FROM snapshots WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? toCamelCase<Snapshot>(result.rows[0]) : null;
|
||||
},
|
||||
|
||||
async deleteOldSnapshots(monitorId: string, keepCount: number): Promise<void> {
|
||||
await query(
|
||||
`DELETE FROM snapshots
|
||||
WHERE monitor_id = $1
|
||||
AND id NOT IN (
|
||||
SELECT id FROM snapshots
|
||||
WHERE monitor_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
)`,
|
||||
[monitorId, keepCount]
|
||||
);
|
||||
},
|
||||
|
||||
async deleteOldSnapshotsByAge(monitorId: string, retentionDays: number): Promise<void> {
|
||||
await query(
|
||||
`DELETE FROM snapshots
|
||||
WHERE monitor_id = $1
|
||||
AND created_at < NOW() - INTERVAL '1 day' * $2`,
|
||||
[monitorId, retentionDays]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
alerts: {
|
||||
async create(data: Omit<Alert, 'id' | 'createdAt' | 'deliveredAt' | 'readAt'>): Promise<Alert> {
|
||||
const result = await query(
|
||||
`INSERT INTO alerts (
|
||||
monitor_id, snapshot_id, user_id, type, title, summary, channels
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[
|
||||
data.monitorId,
|
||||
data.snapshotId,
|
||||
data.userId,
|
||||
data.type,
|
||||
data.title,
|
||||
data.summary || null,
|
||||
JSON.stringify(data.channels),
|
||||
]
|
||||
);
|
||||
return toCamelCase<Alert>(result.rows[0]);
|
||||
},
|
||||
|
||||
async findByUserId(userId: string, limit = 50): Promise<Alert[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM alerts WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<Alert>(row));
|
||||
},
|
||||
|
||||
async markAsDelivered(id: string): Promise<void> {
|
||||
await query('UPDATE alerts SET delivered_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
await query('UPDATE alerts SET read_at = NOW() WHERE id = $1', [id]);
|
||||
},
|
||||
|
||||
async updateChannels(id: string, channels: string[]): Promise<void> {
|
||||
await query('UPDATE alerts SET channels = $1 WHERE id = $2', [JSON.stringify(channels), id]);
|
||||
},
|
||||
},
|
||||
|
||||
webhookLogs: {
|
||||
async create(data: {
|
||||
userId: string;
|
||||
monitorId?: string;
|
||||
alertId?: string;
|
||||
webhookType: 'webhook' | 'slack';
|
||||
url: string;
|
||||
payload?: any;
|
||||
statusCode?: number;
|
||||
responseBody?: string;
|
||||
success: boolean;
|
||||
errorMessage?: string;
|
||||
attempt?: number;
|
||||
}): Promise<{ id: string }> {
|
||||
const result = await query(
|
||||
`INSERT INTO webhook_logs (
|
||||
user_id, monitor_id, alert_id, webhook_type, url, payload,
|
||||
status_code, response_body, success, error_message, attempt
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
|
||||
[
|
||||
data.userId,
|
||||
data.monitorId || null,
|
||||
data.alertId || null,
|
||||
data.webhookType,
|
||||
data.url,
|
||||
data.payload ? JSON.stringify(data.payload) : null,
|
||||
data.statusCode || null,
|
||||
data.responseBody || null,
|
||||
data.success,
|
||||
data.errorMessage || null,
|
||||
data.attempt || 1,
|
||||
]
|
||||
);
|
||||
return { id: result.rows[0].id };
|
||||
},
|
||||
|
||||
async findByUserId(userId: string, limit = 100): Promise<any[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM webhook_logs WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2',
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<any>(row));
|
||||
},
|
||||
|
||||
async findFailedByUserId(userId: string, limit = 50): Promise<any[]> {
|
||||
const result = await query(
|
||||
'SELECT * FROM webhook_logs WHERE user_id = $1 AND success = false ORDER BY created_at DESC LIMIT $2',
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows.map(row => toCamelCase<any>(row));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default db;
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
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 runMigration() {
|
||||
console.log('🔄 Running database migrations...');
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const schemaSql = fs.readFileSync(schemaPath, 'utf-8');
|
||||
|
||||
console.log('📝 Executing schema...');
|
||||
await client.query(schemaSql);
|
||||
|
||||
console.log('✅ Migrations completed successfully!');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
runMigration();
|
||||
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 runMigration() {
|
||||
console.log('🔄 Running database migrations...');
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const schemaSql = fs.readFileSync(schemaPath, 'utf-8');
|
||||
|
||||
console.log('📝 Executing schema...');
|
||||
await client.query(schemaSql);
|
||||
|
||||
console.log('✅ Migrations completed successfully!');
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
runMigration();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
-- Migration: Add notification settings to users table
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_enabled BOOLEAN DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS webhook_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS webhook_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS slack_webhook_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS slack_enabled BOOLEAN DEFAULT false;
|
||||
|
||||
-- Add index for webhook lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- Migration: Add email verification to users table
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP;
|
||||
|
||||
-- Add index for quick lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
-- Migration: Add webhook delivery logs table
|
||||
-- For tracking webhook/slack delivery attempts and debugging
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
|
||||
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
|
||||
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
|
||||
url TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
status_code INTEGER,
|
||||
response_body TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_message TEXT,
|
||||
attempt INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_success ON webhook_logs(success) WHERE success = false;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- Add summary column to snapshots table
|
||||
-- This stores human-readable summaries of changes (e.g., "3 text blocks changed, 2 new links added")
|
||||
|
||||
ALTER TABLE snapshots
|
||||
ADD COLUMN summary TEXT;
|
||||
|
||||
-- Add index for faster queries when filtering by summary existence
|
||||
CREATE INDEX idx_snapshots_summary ON snapshots(summary) WHERE summary IS NOT NULL;
|
||||
|
||||
-- Comment
|
||||
COMMENT ON COLUMN snapshots.summary IS 'Human-readable change summary generated by simple HTML parsing or AI';
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend/src/db/migrations
|
||||
|
|
@ -1,93 +1,123 @@
|
|||
-- Database schema for Website Change Detection Monitor
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
plan VARCHAR(20) DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'business', 'enterprise')),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_plan ON users(plan);
|
||||
|
||||
-- Monitors table
|
||||
CREATE TABLE IF NOT EXISTS monitors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
frequency INTEGER NOT NULL DEFAULT 60 CHECK (frequency > 0),
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'error')),
|
||||
element_selector TEXT,
|
||||
ignore_rules JSONB,
|
||||
keyword_rules JSONB,
|
||||
last_checked_at TIMESTAMP,
|
||||
last_changed_at TIMESTAMP,
|
||||
consecutive_errors INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
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);
|
||||
|
||||
-- Snapshots table
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
html_content TEXT,
|
||||
text_content TEXT,
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
screenshot_url TEXT,
|
||||
http_status INTEGER NOT NULL,
|
||||
response_time INTEGER,
|
||||
changed BOOLEAN DEFAULT false,
|
||||
change_percentage DECIMAL(5,2),
|
||||
error_message TEXT,
|
||||
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);
|
||||
|
||||
-- Alerts table
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
snapshot_id UUID NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('change', 'error', 'keyword')),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
summary TEXT,
|
||||
channels JSONB NOT NULL DEFAULT '["email"]',
|
||||
delivered_at TIMESTAMP,
|
||||
read_at TIMESTAMP,
|
||||
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);
|
||||
|
||||
-- Update timestamps trigger
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_monitors_updated_at BEFORE UPDATE ON monitors
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
-- Database schema for Website Change Detection Monitor
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
plan VARCHAR(20) DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'business', 'enterprise')),
|
||||
stripe_customer_id VARCHAR(255),
|
||||
email_enabled BOOLEAN DEFAULT true,
|
||||
webhook_url TEXT,
|
||||
webhook_enabled BOOLEAN DEFAULT false,
|
||||
slack_webhook_url TEXT,
|
||||
slack_enabled BOOLEAN DEFAULT false,
|
||||
email_verified BOOLEAN DEFAULT false,
|
||||
email_verified_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login_at TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_email ON users(email);
|
||||
CREATE INDEX idx_users_plan ON users(plan);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_webhook_enabled ON users(webhook_enabled) WHERE webhook_enabled = true;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_slack_enabled ON users(slack_enabled) WHERE slack_enabled = true;
|
||||
|
||||
-- Monitors table
|
||||
CREATE TABLE IF NOT EXISTS monitors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
frequency INTEGER NOT NULL DEFAULT 60 CHECK (frequency > 0),
|
||||
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'paused', 'error')),
|
||||
element_selector TEXT,
|
||||
ignore_rules JSONB,
|
||||
keyword_rules JSONB,
|
||||
last_checked_at TIMESTAMP,
|
||||
last_changed_at TIMESTAMP,
|
||||
consecutive_errors INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
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);
|
||||
|
||||
-- Snapshots table
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
html_content TEXT,
|
||||
text_content TEXT,
|
||||
content_hash VARCHAR(64) NOT NULL,
|
||||
screenshot_url TEXT,
|
||||
http_status INTEGER NOT NULL,
|
||||
response_time INTEGER,
|
||||
changed BOOLEAN DEFAULT false,
|
||||
change_percentage DECIMAL(5,2),
|
||||
error_message TEXT,
|
||||
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);
|
||||
|
||||
-- Alerts table
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
monitor_id UUID NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
||||
snapshot_id UUID NOT NULL REFERENCES snapshots(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('change', 'error', 'keyword')),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
summary TEXT,
|
||||
channels JSONB NOT NULL DEFAULT '["email"]',
|
||||
delivered_at TIMESTAMP,
|
||||
read_at TIMESTAMP,
|
||||
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);
|
||||
|
||||
-- Update timestamps trigger
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_monitors_updated_at BEFORE UPDATE ON monitors
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Webhook delivery logs table
|
||||
CREATE TABLE IF NOT EXISTS webhook_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
monitor_id UUID REFERENCES monitors(id) ON DELETE SET NULL,
|
||||
alert_id UUID REFERENCES alerts(id) ON DELETE SET NULL,
|
||||
webhook_type VARCHAR(20) NOT NULL CHECK (webhook_type IN ('webhook', 'slack')),
|
||||
url TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
status_code INTEGER,
|
||||
response_body TEXT,
|
||||
success BOOLEAN NOT NULL,
|
||||
error_message TEXT,
|
||||
attempt INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_user_id ON webhook_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_monitor_id ON webhook_logs(monitor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_logs_created_at ON webhook_logs(created_at);
|
||||
|
|
|
|||
|
|
@ -1,77 +1,94 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './routes/auth';
|
||||
import monitorRoutes from './routes/monitors';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: process.env.APP_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/monitors', authMiddleware, monitorRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Endpoint not found',
|
||||
path: req.path,
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🔗 API URL: http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM received, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRoutes from './routes/auth';
|
||||
import monitorRoutes from './routes/monitors';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import { authMiddleware } from './middleware/auth';
|
||||
import { apiLimiter, authLimiter } from './middleware/rateLimiter';
|
||||
import { startWorker, shutdownScheduler, getSchedulerStats } from './services/scheduler';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3002;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: [process.env.APP_URL || 'http://localhost:3000', 'http://localhost:3020', 'http://localhost:3021'],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Apply general rate limiter to all API routes
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Request logging
|
||||
app.use((req, _res, next) => {
|
||||
console.log(`${new Date().toISOString()} ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (_req, res) => {
|
||||
const schedulerStats = await getSchedulerStats();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
scheduler: schedulerStats,
|
||||
});
|
||||
});
|
||||
|
||||
import testRoutes from './routes/test';
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/monitors', authMiddleware, monitorRoutes);
|
||||
app.use('/api/settings', authMiddleware, settingsRoutes);
|
||||
app.use('/test', testRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Endpoint not found',
|
||||
path: req.path,
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
// Start Bull queue worker
|
||||
const worker = startWorker();
|
||||
console.log('📋 Bull queue worker initialized');
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🔗 API URL: http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down gracefully...');
|
||||
await worker.close();
|
||||
await shutdownScheduler();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down gracefully...');
|
||||
await worker.close();
|
||||
await shutdownScheduler();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,56 +1,56 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyToken } from '../utils/auth';
|
||||
import { JWTPayload } from '../types';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: JWTPayload;
|
||||
}
|
||||
|
||||
export function authMiddleware(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'No token provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'Invalid or expired token',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalAuthMiddleware(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
req.user = payload;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { verifyToken } from '../utils/auth';
|
||||
import { JWTPayload } from '../types';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: JWTPayload;
|
||||
}
|
||||
|
||||
export function authMiddleware(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'No token provided',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
error: 'unauthorized',
|
||||
message: 'Invalid or expired token',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalAuthMiddleware(
|
||||
req: AuthRequest,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
req.user = payload;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
// General API rate limit
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
message: { error: 'rate_limit_exceeded', message: 'Too many requests, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Strict rate limit for auth endpoints (prevent brute force)
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Limit each IP to 5 requests per windowMs
|
||||
message: { error: 'rate_limit_exceeded', message: 'Too many authentication attempts, please try again later.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true, // Don't count successful logins
|
||||
});
|
||||
|
||||
// Moderate rate limit for monitor checks
|
||||
export const checkLimiter = rateLimit({
|
||||
windowMs: 5 * 60 * 1000, // 5 minutes
|
||||
max: 20, // Limit each IP to 20 manual checks per 5 minutes
|
||||
message: { error: 'rate_limit_exceeded', message: 'Too many manual checks, please wait before trying again.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
|
@ -1,143 +1,368 @@
|
|||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db';
|
||||
import {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
} from '../utils/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = registerSchema.parse(req.body);
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_email',
|
||||
message: 'Invalid email format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_password',
|
||||
message: 'Password does not meet requirements',
|
||||
details: passwordValidation.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await db.users.findByEmail(email);
|
||||
if (existingUser) {
|
||||
res.status(409).json({
|
||||
error: 'user_exists',
|
||||
message: 'User with this email already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await db.users.create(email, passwordHash);
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.status(201).json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to register user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = loginSchema.parse(req.body);
|
||||
|
||||
const user = await db.users.findByEmail(email);
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_credentials',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isValidPassword = await comparePassword(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_credentials',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.users.updateLastLogin(user.id);
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to login',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db';
|
||||
import {
|
||||
hashPassword,
|
||||
comparePassword,
|
||||
generateToken,
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
generatePasswordResetToken,
|
||||
verifyPasswordResetToken,
|
||||
generateEmailVerificationToken,
|
||||
verifyEmailVerificationToken,
|
||||
} from '../utils/auth';
|
||||
import { sendPasswordResetEmail, sendEmailVerification } from '../services/alerter';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = registerSchema.parse(req.body);
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_email',
|
||||
message: 'Invalid email format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidation = validatePassword(password);
|
||||
if (!passwordValidation.valid) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_password',
|
||||
message: 'Password does not meet requirements',
|
||||
details: passwordValidation.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = await db.users.findByEmail(email);
|
||||
if (existingUser) {
|
||||
res.status(409).json({
|
||||
error: 'user_exists',
|
||||
message: 'User with this email already exists',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = await db.users.create(email, passwordHash);
|
||||
|
||||
// Generate verification token and send email
|
||||
const verificationToken = generateEmailVerificationToken(email);
|
||||
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
|
||||
|
||||
try {
|
||||
await sendEmailVerification(email, verificationUrl);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// Continue with registration even if email fails
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.status(201).json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
emailVerified: user.emailVerified || false,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Register error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to register user',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email, password } = loginSchema.parse(req.body);
|
||||
|
||||
const user = await db.users.findByEmail(email);
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_credentials',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isValidPassword = await comparePassword(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_credentials',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.users.updateLastLogin(user.id);
|
||||
|
||||
const token = generateToken(user);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to login',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Forgot Password
|
||||
router.post('/forgot-password', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email() }).parse(req.body);
|
||||
|
||||
const user = await db.users.findByEmail(email);
|
||||
|
||||
// Always return success to prevent email enumeration attacks
|
||||
if (!user) {
|
||||
res.json({ message: 'If that email is registered, you will receive a password reset link' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = generatePasswordResetToken(email);
|
||||
|
||||
// Send reset email
|
||||
const resetUrl = `${process.env.APP_URL || 'http://localhost:3000'}/reset-password/${resetToken}`;
|
||||
|
||||
try {
|
||||
await sendPasswordResetEmail(user.email, resetUrl);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send password reset email:', emailError);
|
||||
// Still return success to user, but log the error
|
||||
}
|
||||
|
||||
res.json({ message: 'If that email is registered, you will receive a password reset link' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid email',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Forgot password error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to process password reset request',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Reset Password
|
||||
router.post('/reset-password', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { token, newPassword } = z.object({
|
||||
token: z.string(),
|
||||
newPassword: z.string().min(8),
|
||||
}).parse(req.body);
|
||||
|
||||
// Verify token
|
||||
let email: string;
|
||||
try {
|
||||
const decoded = verifyPasswordResetToken(token);
|
||||
email = decoded.email;
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired reset token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate new password
|
||||
const passwordValidation = validatePassword(newPassword);
|
||||
if (!passwordValidation.valid) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_password',
|
||||
message: 'Password does not meet requirements',
|
||||
details: passwordValidation.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await db.users.findByEmail(email);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
error: 'user_not_found',
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
const newPasswordHash = await hashPassword(newPassword);
|
||||
await db.users.updatePassword(user.id, newPasswordHash);
|
||||
|
||||
res.json({ message: 'Password reset successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Reset password error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to reset password',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Email
|
||||
router.post('/verify-email', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { token } = z.object({ token: z.string() }).parse(req.body);
|
||||
|
||||
// Verify token
|
||||
let email: string;
|
||||
try {
|
||||
const decoded = verifyEmailVerificationToken(token);
|
||||
email = decoded.email;
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired verification token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user
|
||||
const user = await db.users.findByEmail(email);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
error: 'user_not_found',
|
||||
message: 'User not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (user.emailVerified) {
|
||||
res.json({ message: 'Email already verified' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
await db.users.verifyEmail(email);
|
||||
|
||||
res.json({ message: 'Email verified successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Verify email error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to verify email',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Resend Verification Email
|
||||
router.post('/resend-verification', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { email } = z.object({ email: z.string().email() }).parse(req.body);
|
||||
|
||||
const user = await db.users.findByEmail(email);
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
if (!user || user.emailVerified) {
|
||||
res.json({ message: 'If that email needs verification, a new link has been sent' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new verification token
|
||||
const verificationToken = generateEmailVerificationToken(email);
|
||||
const verificationUrl = `${process.env.APP_URL || 'http://localhost:3000'}/verify-email/${verificationToken}`;
|
||||
|
||||
try {
|
||||
await sendEmailVerification(email, verificationUrl);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to resend verification email:', emailError);
|
||||
}
|
||||
|
||||
res.json({ message: 'If that email needs verification, a new link has been sent' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid email',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Resend verification error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
message: 'Failed to resend verification email',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1,371 +1,614 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { CreateMonitorInput, UpdateMonitorInput } from '../types';
|
||||
import { checkMonitor } from '../services/monitor';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const createMonitorSchema = z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string().optional(),
|
||||
frequency: z.number().int().positive(),
|
||||
elementSelector: z.string().optional(),
|
||||
ignoreRules: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(['css', 'regex', 'text']),
|
||||
value: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
keywordRules: z
|
||||
.array(
|
||||
z.object({
|
||||
keyword: z.string(),
|
||||
type: z.enum(['appears', 'disappears', 'count']),
|
||||
threshold: z.number().optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const updateMonitorSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
frequency: z.number().int().positive().optional(),
|
||||
status: z.enum(['active', 'paused', 'error']).optional(),
|
||||
elementSelector: z.string().optional(),
|
||||
ignoreRules: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(['css', 'regex', 'text']),
|
||||
value: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
keywordRules: z
|
||||
.array(
|
||||
z.object({
|
||||
keyword: z.string(),
|
||||
type: z.enum(['appears', 'disappears', 'count']),
|
||||
threshold: z.number().optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Get plan limits
|
||||
function getPlanLimits(plan: string) {
|
||||
const limits = {
|
||||
free: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_FREE || '5'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_FREE || '60'),
|
||||
},
|
||||
pro: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_PRO || '50'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_PRO || '5'),
|
||||
},
|
||||
business: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_BUSINESS || '200'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_BUSINESS || '1'),
|
||||
},
|
||||
enterprise: {
|
||||
maxMonitors: 999999,
|
||||
minFrequency: 1,
|
||||
},
|
||||
};
|
||||
|
||||
return limits[plan as keyof typeof limits] || limits.free;
|
||||
}
|
||||
|
||||
// List monitors
|
||||
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitors = await db.monitors.findByUserId(req.user.userId);
|
||||
|
||||
res.json({ monitors });
|
||||
} catch (error) {
|
||||
console.error('List monitors error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get monitor by ID
|
||||
router.get('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ monitor });
|
||||
} catch (error) {
|
||||
console.error('Get monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create monitor
|
||||
router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = createMonitorSchema.parse(req.body);
|
||||
|
||||
// Check plan limits
|
||||
const limits = getPlanLimits(req.user.plan);
|
||||
const currentCount = await db.monitors.countByUserId(req.user.userId);
|
||||
|
||||
if (currentCount >= limits.maxMonitors) {
|
||||
res.status(403).json({
|
||||
error: 'limit_exceeded',
|
||||
message: `Your ${req.user.plan} plan allows max ${limits.maxMonitors} monitors`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${req.user.plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domain from URL for name if not provided
|
||||
const name = input.name || new URL(input.url).hostname;
|
||||
|
||||
const monitor = await db.monitors.create({
|
||||
userId: req.user.userId,
|
||||
url: input.url,
|
||||
name,
|
||||
frequency: input.frequency,
|
||||
status: 'active',
|
||||
elementSelector: input.elementSelector,
|
||||
ignoreRules: input.ignoreRules,
|
||||
keywordRules: input.keywordRules,
|
||||
});
|
||||
|
||||
// Perform first check immediately
|
||||
checkMonitor(monitor.id).catch((err) =>
|
||||
console.error('Initial check failed:', err)
|
||||
);
|
||||
|
||||
res.status(201).json({ monitor });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Create monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to create monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update monitor
|
||||
router.put('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = updateMonitorSchema.parse(req.body);
|
||||
|
||||
// Check frequency limit if being updated
|
||||
if (input.frequency) {
|
||||
const limits = getPlanLimits(req.user.plan);
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${req.user.plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.monitors.update(req.params.id, input);
|
||||
|
||||
res.json({ monitor: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Update monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to update monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete monitor
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
await db.monitors.delete(req.params.id);
|
||||
|
||||
res.json({ message: 'Monitor deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to delete monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger manual check
|
||||
router.post('/:id/check', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger check (don't wait for it)
|
||||
checkMonitor(monitor.id).catch((err) => console.error('Manual check failed:', err));
|
||||
|
||||
res.json({ message: 'Check triggered successfully' });
|
||||
} catch (error) {
|
||||
console.error('Trigger check error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to trigger check' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get monitor history (snapshots)
|
||||
router.get('/:id/history', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const snapshots = await db.snapshots.findByMonitorId(req.params.id, limit);
|
||||
|
||||
res.json({ snapshots });
|
||||
} catch (error) {
|
||||
console.error('Get history error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific snapshot
|
||||
router.get(
|
||||
'/:id/history/:snapshotId',
|
||||
async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await db.snapshots.findById(req.params.snapshotId);
|
||||
|
||||
if (!snapshot || snapshot.monitorId !== req.params.id) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ snapshot });
|
||||
}catch (error) {
|
||||
console.error('Get snapshot error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get snapshot' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import db from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { checkLimiter } from '../middleware/rateLimiter';
|
||||
import { MonitorFrequency, Monitor } from '../types';
|
||||
import { checkMonitor, scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from '../services/monitor';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const createMonitorSchema = z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string().optional(),
|
||||
frequency: z.number().int().positive(),
|
||||
elementSelector: z.string().optional(),
|
||||
ignoreRules: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(['css', 'regex', 'text']),
|
||||
value: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
keywordRules: z
|
||||
.array(
|
||||
z.object({
|
||||
keyword: z.string(),
|
||||
type: z.enum(['appears', 'disappears', 'count']),
|
||||
threshold: z.number().optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const updateMonitorSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
frequency: z.number().int().positive().optional(),
|
||||
status: z.enum(['active', 'paused', 'error']).optional(),
|
||||
elementSelector: z.string().optional(),
|
||||
ignoreRules: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum(['css', 'regex', 'text']),
|
||||
value: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
keywordRules: z
|
||||
.array(
|
||||
z.object({
|
||||
keyword: z.string(),
|
||||
type: z.enum(['appears', 'disappears', 'count']),
|
||||
threshold: z.number().optional(),
|
||||
caseSensitive: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Get plan limits
|
||||
function getPlanLimits(plan: string) {
|
||||
const limits = {
|
||||
free: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_FREE || '5'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_FREE || '60'),
|
||||
},
|
||||
pro: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_PRO || '50'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_PRO || '5'),
|
||||
},
|
||||
business: {
|
||||
maxMonitors: parseInt(process.env.MAX_MONITORS_BUSINESS || '200'),
|
||||
minFrequency: parseInt(process.env.MIN_FREQUENCY_BUSINESS || '1'),
|
||||
},
|
||||
enterprise: {
|
||||
maxMonitors: 999999,
|
||||
minFrequency: 1,
|
||||
},
|
||||
};
|
||||
|
||||
return limits[plan as keyof typeof limits] || limits.free;
|
||||
}
|
||||
|
||||
// List monitors
|
||||
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
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) => {
|
||||
// Get last 20 snapshots for sparkline
|
||||
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
|
||||
return {
|
||||
...monitor,
|
||||
recentSnapshots
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({ monitors: monitorsWithSnapshots });
|
||||
} catch (error) {
|
||||
console.error('List monitors error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get monitor by ID
|
||||
router.get('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ monitor });
|
||||
} catch (error) {
|
||||
console.error('Get monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create monitor
|
||||
router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = createMonitorSchema.parse(req.body);
|
||||
|
||||
// Check plan limits (fetch fresh user data)
|
||||
const currentUser = await db.users.findById(req.user.userId);
|
||||
const plan = currentUser?.plan || req.user.plan;
|
||||
const limits = getPlanLimits(plan);
|
||||
const currentCount = await db.monitors.countByUserId(req.user.userId);
|
||||
|
||||
if (currentCount >= limits.maxMonitors) {
|
||||
res.status(403).json({
|
||||
error: 'limit_exceeded',
|
||||
message: `Your ${plan} plan allows max ${limits.maxMonitors} monitors`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract domain from URL for name if not provided
|
||||
const name = input.name || new URL(input.url).hostname;
|
||||
|
||||
const monitor = await db.monitors.create({
|
||||
userId: req.user.userId,
|
||||
url: input.url,
|
||||
name,
|
||||
frequency: input.frequency as MonitorFrequency,
|
||||
status: 'active',
|
||||
elementSelector: input.elementSelector,
|
||||
ignoreRules: input.ignoreRules,
|
||||
keywordRules: input.keywordRules,
|
||||
});
|
||||
|
||||
// Schedule recurring checks
|
||||
try {
|
||||
await scheduleMonitor(monitor);
|
||||
console.log(`Monitor ${monitor.id} scheduled successfully`);
|
||||
} catch (err) {
|
||||
console.error('Failed to schedule monitor:', err);
|
||||
}
|
||||
|
||||
// Perform first check immediately
|
||||
checkMonitor(monitor.id).catch((err) =>
|
||||
console.error('Initial check failed:', err)
|
||||
);
|
||||
|
||||
res.status(201).json({ monitor });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Create monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to create monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update monitor
|
||||
router.put('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = updateMonitorSchema.parse(req.body);
|
||||
|
||||
// Check frequency limit if being updated
|
||||
if (input.frequency) {
|
||||
// Fetch fresh user data to get current plan
|
||||
const currentUser = await db.users.findById(req.user.userId);
|
||||
const plan = currentUser?.plan || req.user.plan;
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
if (input.frequency < limits.minFrequency) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_frequency',
|
||||
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Partial<Monitor> = {
|
||||
...input,
|
||||
frequency: input.frequency as MonitorFrequency | undefined,
|
||||
};
|
||||
const updated = await db.monitors.update(req.params.id, updateData);
|
||||
|
||||
if (!updated) {
|
||||
res.status(500).json({ error: 'update_failed', message: 'Failed to update monitor' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reschedule if frequency changed or status changed to/from active
|
||||
const needsRescheduling =
|
||||
input.frequency !== undefined ||
|
||||
(input.status && (input.status === 'active' || monitor.status === 'active'));
|
||||
|
||||
if (needsRescheduling) {
|
||||
try {
|
||||
if (updated.status === 'active') {
|
||||
await rescheduleMonitor(updated);
|
||||
console.log(`Monitor ${updated.id} rescheduled`);
|
||||
} else {
|
||||
await unscheduleMonitor(updated.id);
|
||||
console.log(`Monitor ${updated.id} unscheduled (status: ${updated.status})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reschedule monitor:', err);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ monitor: updated });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Update monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to update monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete monitor
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Unschedule before deleting
|
||||
try {
|
||||
await unscheduleMonitor(req.params.id);
|
||||
console.log(`Monitor ${req.params.id} unscheduled before deletion`);
|
||||
} catch (err) {
|
||||
console.error('Failed to unschedule monitor:', err);
|
||||
}
|
||||
|
||||
await db.monitors.delete(req.params.id);
|
||||
|
||||
res.json({ message: 'Monitor deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete monitor error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to delete monitor' });
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger manual check
|
||||
router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
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;
|
||||
}
|
||||
|
||||
// Await the check so user gets immediate feedback
|
||||
try {
|
||||
await checkMonitor(monitor.id);
|
||||
|
||||
// Get the latest snapshot to return to the user
|
||||
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
const updatedMonitor = await db.monitors.findById(monitor.id);
|
||||
|
||||
res.json({
|
||||
message: 'Check completed successfully',
|
||||
monitor: updatedMonitor,
|
||||
snapshot: latestSnapshot ? {
|
||||
id: latestSnapshot.id,
|
||||
changed: latestSnapshot.changed,
|
||||
changePercentage: latestSnapshot.changePercentage,
|
||||
httpStatus: latestSnapshot.httpStatus,
|
||||
responseTime: latestSnapshot.responseTime,
|
||||
createdAt: latestSnapshot.createdAt,
|
||||
errorMessage: latestSnapshot.errorMessage,
|
||||
} : null,
|
||||
});
|
||||
} catch (checkError: any) {
|
||||
console.error('Check failed:', checkError);
|
||||
res.status(500).json({
|
||||
error: 'check_failed',
|
||||
message: checkError.message || 'Failed to check monitor'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Trigger check error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to trigger check' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get monitor history (snapshots)
|
||||
router.get('/:id/history', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const snapshots = await db.snapshots.findByMonitorId(req.params.id, limit);
|
||||
|
||||
res.json({ snapshots });
|
||||
} catch (error) {
|
||||
console.error('Get history error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get specific snapshot
|
||||
router.get(
|
||||
'/:id/history/:snapshotId',
|
||||
async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await db.snapshots.findById(req.params.snapshotId);
|
||||
|
||||
if (!snapshot || snapshot.monitorId !== req.params.id) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Snapshot not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ snapshot });
|
||||
} catch (error) {
|
||||
console.error('Get snapshot error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get snapshot' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Export monitor audit trail (JSON or CSV)
|
||||
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has export feature (PRO+)
|
||||
const user = await db.users.findById(req.user.userId);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'not_found', message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow export for all users for now, but in production check plan
|
||||
// if (!hasFeature(user.plan, 'audit_export')) {
|
||||
// res.status(403).json({ error: 'forbidden', message: 'Export feature requires Pro plan' });
|
||||
// return;
|
||||
// }
|
||||
|
||||
const monitor = await db.monitors.findById(req.params.id);
|
||||
|
||||
if (!monitor) {
|
||||
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.userId !== req.user.userId) {
|
||||
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const format = (req.query.format as string)?.toLowerCase() || 'json';
|
||||
const fromDate = req.query.from ? new Date(req.query.from as string) : undefined;
|
||||
const toDate = req.query.to ? new Date(req.query.to as string) : undefined;
|
||||
|
||||
// Get all snapshots (up to 1000)
|
||||
let snapshots = await db.snapshots.findByMonitorId(monitor.id, 1000);
|
||||
|
||||
// Filter by date range if provided
|
||||
if (fromDate) {
|
||||
snapshots = snapshots.filter(s => new Date(s.createdAt) >= fromDate);
|
||||
}
|
||||
if (toDate) {
|
||||
snapshots = snapshots.filter(s => new Date(s.createdAt) <= toDate);
|
||||
}
|
||||
|
||||
// Get alerts for this monitor
|
||||
const allAlerts = await db.alerts.findByUserId(req.user.userId, 1000);
|
||||
const monitorAlerts = allAlerts.filter(a => a.monitorId === monitor.id);
|
||||
|
||||
// Filter alerts by date range if provided
|
||||
let filteredAlerts = monitorAlerts;
|
||||
if (fromDate) {
|
||||
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) >= fromDate);
|
||||
}
|
||||
if (toDate) {
|
||||
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) <= toDate);
|
||||
}
|
||||
|
||||
const exportData = {
|
||||
monitor: {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
url: monitor.url,
|
||||
frequency: monitor.frequency,
|
||||
status: monitor.status,
|
||||
createdAt: monitor.createdAt,
|
||||
},
|
||||
exportedAt: new Date().toISOString(),
|
||||
dateRange: {
|
||||
from: fromDate?.toISOString() || 'start',
|
||||
to: toDate?.toISOString() || 'now',
|
||||
},
|
||||
summary: {
|
||||
totalChecks: snapshots.length,
|
||||
changesDetected: snapshots.filter(s => s.changed).length,
|
||||
errorsDetected: snapshots.filter(s => s.errorMessage).length,
|
||||
totalAlerts: filteredAlerts.length,
|
||||
},
|
||||
checks: snapshots.map(s => ({
|
||||
id: s.id,
|
||||
timestamp: s.createdAt,
|
||||
changed: s.changed,
|
||||
changePercentage: s.changePercentage,
|
||||
httpStatus: s.httpStatus,
|
||||
responseTime: s.responseTime,
|
||||
errorMessage: s.errorMessage,
|
||||
})),
|
||||
alerts: filteredAlerts.map(a => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
summary: a.summary,
|
||||
channels: a.channels,
|
||||
createdAt: a.createdAt,
|
||||
deliveredAt: a.deliveredAt,
|
||||
})),
|
||||
};
|
||||
|
||||
if (format === 'csv') {
|
||||
// Generate CSV
|
||||
const csvLines: string[] = [];
|
||||
|
||||
// Header
|
||||
csvLines.push('Type,Timestamp,Changed,Change %,HTTP Status,Response Time (ms),Error,Alert Type,Alert Title');
|
||||
|
||||
// Checks
|
||||
for (const check of exportData.checks) {
|
||||
csvLines.push([
|
||||
'check',
|
||||
check.timestamp,
|
||||
check.changed ? 'true' : 'false',
|
||||
check.changePercentage?.toFixed(2) || '',
|
||||
check.httpStatus,
|
||||
check.responseTime,
|
||||
`"${(check.errorMessage || '').replace(/"/g, '""')}"`,
|
||||
'',
|
||||
'',
|
||||
].join(','));
|
||||
}
|
||||
|
||||
// Alerts
|
||||
for (const alert of exportData.alerts) {
|
||||
csvLines.push([
|
||||
'alert',
|
||||
alert.createdAt,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
alert.type,
|
||||
`"${(alert.title || '').replace(/"/g, '""')}"`,
|
||||
].join(','));
|
||||
}
|
||||
|
||||
const csv = csvLines.join('\n');
|
||||
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
} else {
|
||||
// JSON format
|
||||
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.json`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.json(exportData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to export audit trail' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import { Router, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
const updateNotificationsSchema = z.object({
|
||||
emailEnabled: z.boolean().optional(),
|
||||
webhookUrl: z.string().url().optional().nullable(),
|
||||
webhookEnabled: z.boolean().optional(),
|
||||
slackWebhookUrl: z.string().url().optional().nullable(),
|
||||
slackEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Get user settings
|
||||
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await db.users.findById(req.user.userId);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'not_found', message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Return user settings (exclude password hash)
|
||||
res.json({
|
||||
settings: {
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
emailEnabled: user.emailEnabled ?? true,
|
||||
webhookUrl: user.webhookUrl,
|
||||
webhookEnabled: user.webhookEnabled ?? false,
|
||||
slackWebhookUrl: user.slackWebhookUrl,
|
||||
slackEnabled: user.slackEnabled ?? false,
|
||||
createdAt: user.createdAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get settings error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to get settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = changePasswordSchema.parse(req.body);
|
||||
|
||||
const user = await db.users.findById(req.user.userId);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'not_found', message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(input.currentPassword, user.passwordHash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({ error: 'invalid_password', message: 'Current password is incorrect' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(input.newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.users.updatePassword(req.user.userId, newPasswordHash);
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update notification preferences
|
||||
router.put('/notifications', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input = updateNotificationsSchema.parse(req.body);
|
||||
|
||||
await db.users.updateNotificationSettings(req.user.userId, {
|
||||
emailEnabled: input.emailEnabled,
|
||||
webhookUrl: input.webhookUrl,
|
||||
webhookEnabled: input.webhookEnabled,
|
||||
slackWebhookUrl: input.slackWebhookUrl,
|
||||
slackEnabled: input.slackEnabled,
|
||||
});
|
||||
|
||||
res.json({ message: 'Notification settings updated successfully' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid input',
|
||||
details: error.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Update notifications error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to update notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete account
|
||||
router.delete('/account', async (req: AuthRequest, res: Response): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
res.status(400).json({ error: 'validation_error', message: 'Password is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await db.users.findById(req.user.userId);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'not_found', message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify password before deletion
|
||||
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
res.status(401).json({ error: 'invalid_password', message: 'Password is incorrect' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all user's monitors (cascades to snapshots and alerts)
|
||||
const monitors = await db.monitors.findByUserId(req.user.userId);
|
||||
for (const monitor of monitors) {
|
||||
await db.monitors.delete(monitor.id);
|
||||
}
|
||||
|
||||
// Delete user
|
||||
await db.users.delete(req.user.userId);
|
||||
|
||||
res.json({ message: 'Account deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete account error:', error);
|
||||
res.status(500).json({ error: 'server_error', message: 'Failed to delete account' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Router, Response } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
router.get('/dynamic', (_req, res) => {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString();
|
||||
const randomValue = Math.floor(Math.random() * 1000);
|
||||
// Toggle status based on seconds (even/odd) to guarantee change
|
||||
const isNormal = now.getSeconds() % 2 === 0;
|
||||
const statusMessage = isNormal
|
||||
? "System Status: NORMAL - Everything is running smoothly."
|
||||
: "System Status: WARNING - High load detected on server node!";
|
||||
const statusColor = isNormal ? "green" : "red";
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dynamic Test Page</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 20px; }
|
||||
.time { font-size: 2em; color: #0066cc; }
|
||||
.status { font-size: 1.5em; color: ${statusColor}; font-weight: bold; padding: 20px; border: 2px solid ${statusColor}; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Website Monitor Test</h1>
|
||||
|
||||
<div class="status">${statusMessage}</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Current Time: <span class="time">${timeString}</span></p>
|
||||
<p>Random Value: <span class="random">${randomValue}</span></p>
|
||||
<p>This page content flips every second to simulate a real website change.</p>
|
||||
<div style="background: #f0f9ff; padding: 15px; margin-top: 20px; border-left: 4px solid #0066cc;">
|
||||
<h3>New Feature Update</h3>
|
||||
<p>We have deployed a new importance scoring update!</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
});
|
||||
|
||||
// Test endpoint that returns a 404 error for testing incident display
|
||||
router.get('/error', (_req, res: Response) => {
|
||||
res.status(404).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>404 Not Found</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>This page intentionally returns a 404 error for testing.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,209 +1,582 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import { Monitor, User, Snapshot } from '../types';
|
||||
import { KeywordMatch } from './differ';
|
||||
import db from '../db';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@websitemonitor.com';
|
||||
|
||||
export async function sendChangeAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
snapshot: Snapshot,
|
||||
changePercentage: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const diffUrl = `${APP_URL}/monitors/${monitor.id}/history/${snapshot.id}`;
|
||||
|
||||
const mailOptions = {
|
||||
from: EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject: `Change detected: ${monitor.name}`,
|
||||
html: `
|
||||
<h2>Change Detected</h2>
|
||||
<p>A change was detected on your monitored page: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
<p><strong>Change Percentage:</strong> ${changePercentage.toFixed(2)}%</p>
|
||||
<p><strong>Detected At:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="${diffUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Changes
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
You're receiving this because you set up monitoring for this page.
|
||||
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
|
||||
// Create alert record
|
||||
await db.alerts.create({
|
||||
monitorId: monitor.id,
|
||||
snapshotId: snapshot.id,
|
||||
userId: user.id,
|
||||
type: 'change',
|
||||
title: `Change detected: ${monitor.name}`,
|
||||
summary: `${changePercentage.toFixed(2)}% of the page changed`,
|
||||
channels: ['email'],
|
||||
});
|
||||
|
||||
console.log(`[Alert] Change alert sent to ${user.email} for monitor ${monitor.name}`);
|
||||
} catch (error) {
|
||||
console.error('[Alert] Failed to send change alert:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendErrorAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
errorMessage: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
|
||||
|
||||
const mailOptions = {
|
||||
from: EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject: `Error monitoring: ${monitor.name}`,
|
||||
html: `
|
||||
<h2>Monitoring Error</h2>
|
||||
<p>We encountered an error while monitoring: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
<p><strong>Error:</strong> ${errorMessage}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>We'll keep trying to check this page. If the problem persists, you may want to verify the URL or check if the site is blocking automated requests.</p>
|
||||
|
||||
<p>
|
||||
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Monitor Settings
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
|
||||
// Create snapshot for error (to track it)
|
||||
const snapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: '',
|
||||
textContent: '',
|
||||
contentHash: '',
|
||||
httpStatus: 0,
|
||||
responseTime: 0,
|
||||
changed: false,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
// Create alert record
|
||||
await db.alerts.create({
|
||||
monitorId: monitor.id,
|
||||
snapshotId: snapshot.id,
|
||||
userId: user.id,
|
||||
type: 'error',
|
||||
title: `Error monitoring: ${monitor.name}`,
|
||||
summary: errorMessage,
|
||||
channels: ['email'],
|
||||
});
|
||||
|
||||
console.log(`[Alert] Error alert sent to ${user.email} for monitor ${monitor.name}`);
|
||||
} catch (error) {
|
||||
console.error('[Alert] Failed to send error alert:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendKeywordAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
match: KeywordMatch
|
||||
): Promise<void> {
|
||||
try {
|
||||
const monitorUrl = `${APP_URL}/monitors/${monitor.id}`;
|
||||
|
||||
let message = '';
|
||||
switch (match.type) {
|
||||
case 'appeared':
|
||||
message = `The keyword "${match.keyword}" appeared on the page`;
|
||||
break;
|
||||
case 'disappeared':
|
||||
message = `The keyword "${match.keyword}" disappeared from the page`;
|
||||
break;
|
||||
case 'count_changed':
|
||||
message = `The keyword "${match.keyword}" count changed from ${match.previousCount} to ${match.currentCount}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject: `Keyword alert: ${monitor.name}`,
|
||||
html: `
|
||||
<h2>Keyword Alert</h2>
|
||||
<p>A keyword you're watching changed on: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #17a2b8;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
<p><strong>Alert:</strong> ${message}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Monitor
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
<a href="${APP_URL}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
|
||||
// Get latest snapshot
|
||||
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
if (snapshot) {
|
||||
// Create alert record
|
||||
await db.alerts.create({
|
||||
monitorId: monitor.id,
|
||||
snapshotId: snapshot.id,
|
||||
userId: user.id,
|
||||
type: 'keyword',
|
||||
title: `Keyword alert: ${monitor.name}`,
|
||||
summary: message,
|
||||
channels: ['email'],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Alert] Keyword alert sent to ${user.email} for monitor ${monitor.name}`);
|
||||
} catch (error) {
|
||||
console.error('[Alert] Failed to send keyword alert:', error);
|
||||
}
|
||||
}
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { Monitor, User, Snapshot, AlertChannel } from '../types';
|
||||
import { KeywordMatch } from './differ';
|
||||
import db from '../db';
|
||||
import { APP_CONFIG, WEBHOOK_CONFIG, hasFeature } from '../config';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Slack Integration
|
||||
// ============================================
|
||||
|
||||
interface SlackMessage {
|
||||
title: string;
|
||||
text: string;
|
||||
url?: string;
|
||||
color?: 'good' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a Slack webhook
|
||||
*/
|
||||
export async function sendSlackNotification(
|
||||
webhookUrl: string,
|
||||
message: SlackMessage,
|
||||
userId: string,
|
||||
monitorId?: string,
|
||||
alertId?: string
|
||||
): Promise<boolean> {
|
||||
const payload = {
|
||||
attachments: [
|
||||
{
|
||||
color: message.color || '#007bff',
|
||||
title: message.title,
|
||||
title_link: message.url,
|
||||
text: message.text,
|
||||
footer: 'Website Monitor',
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let attempt = 1;
|
||||
let lastError: string | undefined;
|
||||
|
||||
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
// Log the attempt
|
||||
await db.webhookLogs.create({
|
||||
userId,
|
||||
monitorId,
|
||||
alertId,
|
||||
webhookType: 'slack',
|
||||
url: webhookUrl,
|
||||
payload,
|
||||
statusCode: response.status,
|
||||
responseBody: responseBody.substring(0, 1000),
|
||||
success: response.ok,
|
||||
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
|
||||
attempt,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`[Slack] Notification sent successfully`);
|
||||
return true;
|
||||
}
|
||||
|
||||
lastError = `HTTP ${response.status}: ${responseBody}`;
|
||||
} catch (error: any) {
|
||||
lastError = error.message || 'Unknown error';
|
||||
|
||||
// Log failed attempt
|
||||
await db.webhookLogs.create({
|
||||
userId,
|
||||
monitorId,
|
||||
alertId,
|
||||
webhookType: 'slack',
|
||||
url: webhookUrl,
|
||||
payload,
|
||||
success: false,
|
||||
errorMessage: lastError,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
if (attempt < WEBHOOK_CONFIG.maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
console.error(`[Slack] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Webhook Integration
|
||||
// ============================================
|
||||
|
||||
interface WebhookPayload {
|
||||
event: 'change' | 'error' | 'keyword';
|
||||
monitor: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
details: {
|
||||
changePercentage?: number;
|
||||
errorMessage?: string;
|
||||
keywordMatch?: KeywordMatch;
|
||||
};
|
||||
timestamp: string;
|
||||
viewUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a generic webhook
|
||||
*/
|
||||
export async function sendWebhookNotification(
|
||||
webhookUrl: string,
|
||||
payload: WebhookPayload,
|
||||
userId: string,
|
||||
monitorId?: string,
|
||||
alertId?: string
|
||||
): Promise<boolean> {
|
||||
let attempt = 1;
|
||||
let lastError: string | undefined;
|
||||
|
||||
while (attempt <= WEBHOOK_CONFIG.maxRetries) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), WEBHOOK_CONFIG.timeoutMs);
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'WebsiteMonitor/1.0',
|
||||
'X-Webhook-Event': payload.event,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
let responseBody = '';
|
||||
try {
|
||||
responseBody = await response.text();
|
||||
} catch {
|
||||
// Ignore response body parsing errors
|
||||
}
|
||||
|
||||
// Log the attempt
|
||||
await db.webhookLogs.create({
|
||||
userId,
|
||||
monitorId,
|
||||
alertId,
|
||||
webhookType: 'webhook',
|
||||
url: webhookUrl,
|
||||
payload: payload as any,
|
||||
statusCode: response.status,
|
||||
responseBody: responseBody.substring(0, 1000),
|
||||
success: response.ok,
|
||||
errorMessage: response.ok ? undefined : `HTTP ${response.status}`,
|
||||
attempt,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log(`[Webhook] Notification sent successfully`);
|
||||
return true;
|
||||
}
|
||||
|
||||
lastError = `HTTP ${response.status}`;
|
||||
} catch (error: any) {
|
||||
lastError = error.message || 'Unknown error';
|
||||
|
||||
// Log failed attempt
|
||||
await db.webhookLogs.create({
|
||||
userId,
|
||||
monitorId,
|
||||
alertId,
|
||||
webhookType: 'webhook',
|
||||
url: webhookUrl,
|
||||
payload: payload as any,
|
||||
success: false,
|
||||
errorMessage: lastError,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
if (attempt < WEBHOOK_CONFIG.maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, WEBHOOK_CONFIG.retryDelayMs * attempt));
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
|
||||
console.error(`[Webhook] Failed after ${WEBHOOK_CONFIG.maxRetries} attempts: ${lastError}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Unified Alert Dispatcher
|
||||
// ============================================
|
||||
|
||||
interface AlertData {
|
||||
title: string;
|
||||
summary: string;
|
||||
htmlContent: string;
|
||||
viewUrl: string;
|
||||
color?: 'good' | 'warning' | 'danger';
|
||||
changePercentage?: number;
|
||||
errorMessage?: string;
|
||||
keywordMatch?: KeywordMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an alert to all configured channels for a user
|
||||
*/
|
||||
async function dispatchAlert(
|
||||
user: User,
|
||||
monitor: Monitor,
|
||||
snapshot: Snapshot | null,
|
||||
alertType: 'change' | 'error' | 'keyword',
|
||||
data: AlertData
|
||||
): Promise<AlertChannel[]> {
|
||||
const usedChannels: AlertChannel[] = [];
|
||||
|
||||
// Create alert record first
|
||||
let alertId: string | undefined;
|
||||
if (snapshot) {
|
||||
const alert = await db.alerts.create({
|
||||
monitorId: monitor.id,
|
||||
snapshotId: snapshot.id,
|
||||
userId: user.id,
|
||||
type: alertType,
|
||||
title: data.title,
|
||||
summary: data.summary,
|
||||
channels: ['email'], // Will be updated after dispatch
|
||||
});
|
||||
alertId = alert.id;
|
||||
}
|
||||
|
||||
// 1. Email (always available)
|
||||
if (user.emailEnabled !== false) {
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: APP_CONFIG.emailFrom,
|
||||
to: user.email,
|
||||
subject: data.title,
|
||||
html: data.htmlContent,
|
||||
});
|
||||
usedChannels.push('email');
|
||||
console.log(`[Alert] Email sent to ${user.email}`);
|
||||
} catch (error) {
|
||||
console.error(`[Alert] Failed to send email:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Slack (PRO+ feature)
|
||||
if (user.slackEnabled && user.slackWebhookUrl && hasFeature(user.plan, 'slack_integration')) {
|
||||
const success = await sendSlackNotification(
|
||||
user.slackWebhookUrl,
|
||||
{
|
||||
title: data.title,
|
||||
text: data.summary,
|
||||
url: data.viewUrl,
|
||||
color: data.color || 'warning',
|
||||
},
|
||||
user.id,
|
||||
monitor.id,
|
||||
alertId
|
||||
);
|
||||
if (success) {
|
||||
usedChannels.push('slack');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Webhook (PRO+ feature)
|
||||
if (user.webhookEnabled && user.webhookUrl && hasFeature(user.plan, 'webhook_integration')) {
|
||||
const webhookPayload: WebhookPayload = {
|
||||
event: alertType,
|
||||
monitor: {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
url: monitor.url,
|
||||
},
|
||||
details: {
|
||||
changePercentage: data.changePercentage,
|
||||
errorMessage: data.errorMessage,
|
||||
keywordMatch: data.keywordMatch,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
viewUrl: data.viewUrl,
|
||||
};
|
||||
|
||||
const success = await sendWebhookNotification(
|
||||
user.webhookUrl,
|
||||
webhookPayload,
|
||||
user.id,
|
||||
monitor.id,
|
||||
alertId
|
||||
);
|
||||
if (success) {
|
||||
usedChannels.push('webhook');
|
||||
}
|
||||
}
|
||||
|
||||
// Update alert with used channels
|
||||
if (alertId && usedChannels.length > 0) {
|
||||
await db.alerts.updateChannels(alertId, usedChannels);
|
||||
await db.alerts.markAsDelivered(alertId);
|
||||
}
|
||||
|
||||
return usedChannels;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Alert Functions (Public API)
|
||||
// ============================================
|
||||
|
||||
export async function sendChangeAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
snapshot: Snapshot,
|
||||
changePercentage: number
|
||||
): Promise<void> {
|
||||
const diffUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}/history/${snapshot.id}`;
|
||||
|
||||
const htmlContent = `
|
||||
<h2>Change Detected</h2>
|
||||
<p>A change was detected on your monitored page: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
${snapshot.summary ? `<p><strong>What Changed:</strong> ${snapshot.summary}</p>` : ''}
|
||||
<p><strong>Change Percentage:</strong> ${changePercentage.toFixed(2)}%</p>
|
||||
<p><strong>Detected At:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="${diffUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Changes
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
You're receiving this because you set up monitoring for this page.
|
||||
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`;
|
||||
|
||||
await dispatchAlert(user, monitor, snapshot, 'change', {
|
||||
title: `Change detected: ${monitor.name}`,
|
||||
summary: snapshot.summary || `${changePercentage.toFixed(2)}% of the page changed`,
|
||||
htmlContent,
|
||||
viewUrl: diffUrl,
|
||||
color: changePercentage > 50 ? 'danger' : changePercentage > 10 ? 'warning' : 'good',
|
||||
changePercentage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendErrorAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
errorMessage: string
|
||||
): Promise<void> {
|
||||
const monitorUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}`;
|
||||
|
||||
const htmlContent = `
|
||||
<h2>Monitoring Error</h2>
|
||||
<p>We encountered an error while monitoring: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
<p><strong>Error:</strong> ${errorMessage}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>We'll keep trying to check this page. If the problem persists, you may want to verify the URL or check if the site is blocking automated requests.</p>
|
||||
|
||||
<p>
|
||||
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Monitor Settings
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`;
|
||||
|
||||
// Create snapshot for error (to track it)
|
||||
const snapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
htmlContent: '',
|
||||
textContent: '',
|
||||
contentHash: '',
|
||||
httpStatus: 0,
|
||||
responseTime: 0,
|
||||
changed: false,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
await dispatchAlert(user, monitor, snapshot, 'error', {
|
||||
title: `Error monitoring: ${monitor.name}`,
|
||||
summary: errorMessage,
|
||||
htmlContent,
|
||||
viewUrl: monitorUrl,
|
||||
color: 'danger',
|
||||
errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendKeywordAlert(
|
||||
monitor: Monitor,
|
||||
user: User,
|
||||
match: KeywordMatch
|
||||
): Promise<void> {
|
||||
const monitorUrl = `${APP_CONFIG.appUrl}/monitors/${monitor.id}`;
|
||||
|
||||
let message = '';
|
||||
switch (match.type) {
|
||||
case 'appeared':
|
||||
message = `The keyword "${match.keyword}" appeared on the page`;
|
||||
break;
|
||||
case 'disappeared':
|
||||
message = `The keyword "${match.keyword}" disappeared from the page`;
|
||||
break;
|
||||
case 'count_changed':
|
||||
message = `The keyword "${match.keyword}" count changed from ${match.previousCount} to ${match.currentCount}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const htmlContent = `
|
||||
<h2>Keyword Alert</h2>
|
||||
<p>A keyword you're watching changed on: <strong>${monitor.name}</strong></p>
|
||||
|
||||
<div style="background: #d1ecf1; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #17a2b8;">
|
||||
<p><strong>URL:</strong> <a href="${monitor.url}">${monitor.url}</a></p>
|
||||
<p><strong>Alert:</strong> ${message}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<a href="${monitorUrl}" style="background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
||||
View Monitor
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="color: #666; font-size: 12px; margin-top: 30px;">
|
||||
<a href="${APP_CONFIG.appUrl}/monitors/${monitor.id}">Manage this monitor</a>
|
||||
</p>
|
||||
`;
|
||||
|
||||
// Get latest snapshot
|
||||
const snapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
|
||||
await dispatchAlert(user, monitor, snapshot, 'keyword', {
|
||||
title: `Keyword alert: ${monitor.name}`,
|
||||
summary: message,
|
||||
htmlContent,
|
||||
viewUrl: monitorUrl,
|
||||
color: match.type === 'appeared' ? 'good' : 'warning',
|
||||
keywordMatch: match,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Email-only Functions (Auth flows)
|
||||
// ============================================
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
resetUrl: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mailOptions = {
|
||||
from: APP_CONFIG.emailFrom,
|
||||
to: email,
|
||||
subject: 'Password Reset Request',
|
||||
html: `
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested to reset your password for your Website Monitor account.</p>
|
||||
|
||||
<p>Click the button below to reset your password. This link will expire in 1 hour.</p>
|
||||
|
||||
<p>
|
||||
<a href="${resetUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
|
||||
Reset Password
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
|
||||
${resetUrl}
|
||||
</p>
|
||||
|
||||
<p style="color: #666; margin-top: 30px;">
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
Your password will not be changed.
|
||||
</p>
|
||||
|
||||
<p style="color: #999; font-size: 12px; margin-top: 30px;">
|
||||
This is an automated email. Please do not reply.
|
||||
</p>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
console.log(`[Alert] Password reset email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error('[Alert] Failed to send password reset email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(
|
||||
email: string,
|
||||
verificationUrl: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const mailOptions = {
|
||||
from: APP_CONFIG.emailFrom,
|
||||
to: email,
|
||||
subject: 'Verify Your Email - Website Monitor',
|
||||
html: `
|
||||
<h2>Welcome to Website Monitor!</h2>
|
||||
<p>Thank you for signing up. Please verify your email address to activate your account.</p>
|
||||
|
||||
<p>Click the button below to verify your email:</p>
|
||||
|
||||
<p>
|
||||
<a href="${verificationUrl}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 20px 0;">
|
||||
Verify Email Address
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="background: #f5f5f5; padding: 10px; border-radius: 5px; word-break: break-all;">
|
||||
${verificationUrl}
|
||||
</p>
|
||||
|
||||
<p style="color: #666; margin-top: 30px;">
|
||||
This verification link will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<p style="color: #999; font-size: 12px; margin-top: 30px;">
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
console.log(`[Alert] Verification email sent to ${email}`);
|
||||
} catch (error) {
|
||||
console.error('[Alert] Failed to send verification email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,181 +1,199 @@
|
|||
import { diffLines, diffWords, Change } from 'diff';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { IgnoreRule, KeywordRule } from '../types';
|
||||
|
||||
export interface DiffResult {
|
||||
changed: boolean;
|
||||
changePercentage: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
diff: Change[];
|
||||
}
|
||||
|
||||
export interface KeywordMatch {
|
||||
keyword: string;
|
||||
type: 'appeared' | 'disappeared' | 'count_changed';
|
||||
previousCount?: number;
|
||||
currentCount?: number;
|
||||
}
|
||||
|
||||
export function applyIgnoreRules(html: string, rules?: IgnoreRule[]): string {
|
||||
if (!rules || rules.length === 0) return html;
|
||||
|
||||
let processedHtml = html;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
for (const rule of rules) {
|
||||
switch (rule.type) {
|
||||
case 'css':
|
||||
// Remove elements matching CSS selector
|
||||
$(rule.value).remove();
|
||||
processedHtml = $.html();
|
||||
break;
|
||||
|
||||
case 'regex':
|
||||
// Remove text matching regex
|
||||
const regex = new RegExp(rule.value, 'gi');
|
||||
processedHtml = processedHtml.replace(regex, '');
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
// Remove exact text matches
|
||||
processedHtml = processedHtml.replace(
|
||||
new RegExp(rule.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
|
||||
''
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
export function applyCommonNoiseFilters(html: string): string {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Common cookie banner selectors
|
||||
const cookieSelectors = [
|
||||
'[class*="cookie"]',
|
||||
'[id*="cookie"]',
|
||||
'[class*="consent"]',
|
||||
'[id*="consent"]',
|
||||
'[class*="gdpr"]',
|
||||
'[id*="gdpr"]',
|
||||
];
|
||||
|
||||
cookieSelectors.forEach((selector) => {
|
||||
$(selector).remove();
|
||||
});
|
||||
|
||||
let processedHtml = $.html();
|
||||
|
||||
// Remove common timestamp patterns
|
||||
const timestampPatterns = [
|
||||
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi, // ISO timestamps
|
||||
/\d{1,2}\/\d{1,2}\/\d{4}/gi, // MM/DD/YYYY
|
||||
/\d{1,2}-\d{1,2}-\d{4}/gi, // MM-DD-YYYY
|
||||
/Last updated:?\s*\d+/gi,
|
||||
/Updated:?\s*\d+/gi,
|
||||
];
|
||||
|
||||
timestampPatterns.forEach((pattern) => {
|
||||
processedHtml = processedHtml.replace(pattern, '');
|
||||
});
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
export function compareDiffs(
|
||||
previousText: string,
|
||||
currentText: string
|
||||
): DiffResult {
|
||||
const diff = diffLines(previousText, currentText);
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let totalLines = 0;
|
||||
|
||||
diff.forEach((part) => {
|
||||
const lines = part.value.split('\n').filter((line) => line.trim()).length;
|
||||
totalLines += lines;
|
||||
|
||||
if (part.added) {
|
||||
additions += lines;
|
||||
} else if (part.removed) {
|
||||
deletions += lines;
|
||||
}
|
||||
});
|
||||
|
||||
const changedLines = additions + deletions;
|
||||
const changePercentage = totalLines > 0 ? (changedLines / totalLines) * 100 : 0;
|
||||
|
||||
return {
|
||||
changed: additions > 0 || deletions > 0,
|
||||
changePercentage: Math.min(changePercentage, 100),
|
||||
additions,
|
||||
deletions,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkKeywords(
|
||||
previousText: string,
|
||||
currentText: string,
|
||||
rules?: KeywordRule[]
|
||||
): KeywordMatch[] {
|
||||
if (!rules || rules.length === 0) return [];
|
||||
|
||||
const matches: KeywordMatch[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const prevMatches = rule.caseSensitive
|
||||
? (previousText.match(new RegExp(rule.keyword, 'g')) || []).length
|
||||
: (previousText.match(new RegExp(rule.keyword, 'gi')) || []).length;
|
||||
|
||||
const currMatches = rule.caseSensitive
|
||||
? (currentText.match(new RegExp(rule.keyword, 'g')) || []).length
|
||||
: (currentText.match(new RegExp(rule.keyword, 'gi')) || []).length;
|
||||
|
||||
switch (rule.type) {
|
||||
case 'appears':
|
||||
if (prevMatches === 0 && currMatches > 0) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'appeared',
|
||||
currentCount: currMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disappears':
|
||||
if (prevMatches > 0 && currMatches === 0) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'disappeared',
|
||||
previousCount: prevMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
const threshold = rule.threshold || 1;
|
||||
if (Math.abs(currMatches - prevMatches) >= threshold) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'count_changed',
|
||||
previousCount: prevMatches,
|
||||
currentCount: currMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function calculateChangeSeverity(changePercentage: number): 'minor' | 'medium' | 'major' {
|
||||
if (changePercentage > 50) return 'major';
|
||||
if (changePercentage > 10) return 'medium';
|
||||
return 'minor';
|
||||
}
|
||||
import { diffLines, Change } from 'diff';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { IgnoreRule, KeywordRule } from '../types';
|
||||
|
||||
export interface DiffResult {
|
||||
changed: boolean;
|
||||
changePercentage: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
diff: Change[];
|
||||
}
|
||||
|
||||
export interface KeywordMatch {
|
||||
keyword: string;
|
||||
type: 'appeared' | 'disappeared' | 'count_changed';
|
||||
previousCount?: number;
|
||||
currentCount?: number;
|
||||
}
|
||||
|
||||
export function applyIgnoreRules(html: string, rules?: IgnoreRule[]): string {
|
||||
if (!rules || rules.length === 0) return html;
|
||||
|
||||
let processedHtml = html;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
for (const rule of rules) {
|
||||
switch (rule.type) {
|
||||
case 'css':
|
||||
// Remove elements matching CSS selector
|
||||
$(rule.value).remove();
|
||||
processedHtml = $.html();
|
||||
break;
|
||||
|
||||
case 'regex':
|
||||
// Remove text matching regex
|
||||
const regex = new RegExp(rule.value, 'gi');
|
||||
processedHtml = processedHtml.replace(regex, '');
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
// Remove exact text matches
|
||||
processedHtml = processedHtml.replace(
|
||||
new RegExp(rule.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'),
|
||||
''
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
export function applyCommonNoiseFilters(html: string): string {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Common cookie banner selectors
|
||||
const cookieSelectors = [
|
||||
'[class*="cookie"]',
|
||||
'[id*="cookie"]',
|
||||
'[class*="consent"]',
|
||||
'[id*="consent"]',
|
||||
'[class*="gdpr"]',
|
||||
'[id*="gdpr"]',
|
||||
];
|
||||
|
||||
cookieSelectors.forEach((selector) => {
|
||||
$(selector).remove();
|
||||
});
|
||||
|
||||
let processedHtml = $.html();
|
||||
|
||||
// Enhanced timestamp patterns to catch more formats
|
||||
const timestampPatterns = [
|
||||
// ISO timestamps
|
||||
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?/gi,
|
||||
// Date formats
|
||||
/\d{1,2}\/\d{1,2}\/\d{4}/gi, // MM/DD/YYYY
|
||||
/\d{1,2}-\d{1,2}-\d{4}/gi, // MM-DD-YYYY
|
||||
/\d{4}\/\d{1,2}\/\d{1,2}/gi, // YYYY/MM/DD
|
||||
/\d{4}-\d{1,2}-\d{1,2}/gi, // YYYY-MM-DD
|
||||
// Time formats
|
||||
/\d{1,2}:\d{2}:\d{2}/gi, // HH:MM:SS
|
||||
/\d{1,2}:\d{2}\s?(AM|PM|am|pm)/gi, // HH:MM AM/PM
|
||||
// Common date patterns with month names
|
||||
/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}/gi,
|
||||
/\d{1,2}\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{4}/gi,
|
||||
// Timestamps with labels
|
||||
/Last updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
|
||||
/Updated:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
|
||||
/Modified:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
|
||||
/Posted:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
|
||||
/Published:?\s*[\d\-\/:,\s]+(AM|PM)?/gi,
|
||||
// Unix timestamps (10 or 13 digits)
|
||||
/\b\d{10,13}\b/g,
|
||||
// Relative times
|
||||
/\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago/gi,
|
||||
];
|
||||
|
||||
timestampPatterns.forEach((pattern) => {
|
||||
processedHtml = processedHtml.replace(pattern, '');
|
||||
});
|
||||
|
||||
return processedHtml;
|
||||
}
|
||||
|
||||
export function compareDiffs(
|
||||
previousText: string,
|
||||
currentText: string
|
||||
): DiffResult {
|
||||
const diff = diffLines(previousText, currentText);
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
let totalLines = 0;
|
||||
|
||||
diff.forEach((part) => {
|
||||
const lines = part.value.split('\n').filter((line) => line.trim()).length;
|
||||
totalLines += lines;
|
||||
|
||||
if (part.added) {
|
||||
additions += lines;
|
||||
} else if (part.removed) {
|
||||
deletions += lines;
|
||||
}
|
||||
});
|
||||
|
||||
const changedLines = additions + deletions;
|
||||
const changePercentage = totalLines > 0 ? (changedLines / totalLines) * 100 : 0;
|
||||
|
||||
return {
|
||||
changed: additions > 0 || deletions > 0,
|
||||
changePercentage: Math.min(changePercentage, 100),
|
||||
additions,
|
||||
deletions,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkKeywords(
|
||||
previousText: string,
|
||||
currentText: string,
|
||||
rules?: KeywordRule[]
|
||||
): KeywordMatch[] {
|
||||
if (!rules || rules.length === 0) return [];
|
||||
|
||||
const matches: KeywordMatch[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
const prevMatches = rule.caseSensitive
|
||||
? (previousText.match(new RegExp(rule.keyword, 'g')) || []).length
|
||||
: (previousText.match(new RegExp(rule.keyword, 'gi')) || []).length;
|
||||
|
||||
const currMatches = rule.caseSensitive
|
||||
? (currentText.match(new RegExp(rule.keyword, 'g')) || []).length
|
||||
: (currentText.match(new RegExp(rule.keyword, 'gi')) || []).length;
|
||||
|
||||
switch (rule.type) {
|
||||
case 'appears':
|
||||
if (prevMatches === 0 && currMatches > 0) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'appeared',
|
||||
currentCount: currMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disappears':
|
||||
if (prevMatches > 0 && currMatches === 0) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'disappeared',
|
||||
previousCount: prevMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
const threshold = rule.threshold || 1;
|
||||
if (Math.abs(currMatches - prevMatches) >= threshold) {
|
||||
matches.push({
|
||||
keyword: rule.keyword,
|
||||
type: 'count_changed',
|
||||
previousCount: prevMatches,
|
||||
currentCount: currMatches,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function calculateChangeSeverity(changePercentage: number): 'minor' | 'medium' | 'major' {
|
||||
if (changePercentage > 50) return 'major';
|
||||
if (changePercentage > 10) return 'medium';
|
||||
return 'minor';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
import { Queue, Worker } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import nodemailer from 'nodemailer';
|
||||
import db from '../db';
|
||||
|
||||
// Redis connection (reuse from main scheduler)
|
||||
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
// Digest queue
|
||||
export const digestQueue = new Queue('change-digests', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 10,
|
||||
removeOnFail: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Email transporter (same config as alerter)
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || 'noreply@websitemonitor.com';
|
||||
|
||||
interface DigestChange {
|
||||
monitorId: string;
|
||||
monitorName: string;
|
||||
monitorUrl: string;
|
||||
changePercentage: number;
|
||||
changedAt: Date;
|
||||
importanceScore: number;
|
||||
}
|
||||
|
||||
interface DigestUser {
|
||||
id: string;
|
||||
email: string;
|
||||
digestInterval: 'daily' | 'weekly' | 'none';
|
||||
lastDigestAt: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users who need a digest email
|
||||
*/
|
||||
async function getUsersForDigest(interval: 'daily' | 'weekly'): Promise<DigestUser[]> {
|
||||
const cutoffHours = interval === 'daily' ? 24 : 168; // 24h or 7 days
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT id, email,
|
||||
COALESCE(notification_preferences->>'digestInterval', 'none') as "digestInterval",
|
||||
last_digest_at as "lastDigestAt"
|
||||
FROM users
|
||||
WHERE COALESCE(notification_preferences->>'digestInterval', 'none') = $1
|
||||
AND (last_digest_at IS NULL OR last_digest_at < NOW() - INTERVAL '${cutoffHours} hours')`,
|
||||
[interval]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes for a user since their last digest
|
||||
*/
|
||||
async function getChangesForUser(userId: string, since: Date): Promise<DigestChange[]> {
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
m.id as "monitorId",
|
||||
m.name as "monitorName",
|
||||
m.url as "monitorUrl",
|
||||
s.change_percentage as "changePercentage",
|
||||
s.checked_at as "changedAt",
|
||||
COALESCE(s.importance_score, 50) as "importanceScore"
|
||||
FROM monitors m
|
||||
JOIN snapshots s ON s.monitor_id = m.id
|
||||
WHERE m.user_id = $1
|
||||
AND s.has_changes = true
|
||||
AND s.checked_at > $2
|
||||
ORDER BY s.importance_score DESC, s.checked_at DESC
|
||||
LIMIT 50`,
|
||||
[userId, since]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML for the digest email
|
||||
*/
|
||||
function generateDigestHtml(changes: DigestChange[], interval: string): string {
|
||||
const periodText = interval === 'daily' ? 'today' : 'this week';
|
||||
|
||||
if (changes.length === 0) {
|
||||
return `
|
||||
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">📊 Your Change Digest</h2>
|
||||
<p style="color: #666;">No changes detected ${periodText}. All quiet on your monitors!</p>
|
||||
<p style="color: #999; font-size: 12px;">Visit <a href="${APP_URL}/monitors">your dashboard</a> to manage your monitors.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Group by importance
|
||||
const highImportance = changes.filter(c => c.importanceScore >= 70);
|
||||
const mediumImportance = changes.filter(c => c.importanceScore >= 40 && c.importanceScore < 70);
|
||||
const lowImportance = changes.filter(c => c.importanceScore < 40);
|
||||
|
||||
let html = `
|
||||
<div style="font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #333;">📊 Your Change Digest</h2>
|
||||
<p style="color: #666;">Here's what changed ${periodText}:</p>
|
||||
|
||||
<div style="background: #f5f5f0; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||
<strong style="color: #333;">${changes.length} changes</strong> detected across your monitors
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (highImportance.length > 0) {
|
||||
html += `
|
||||
<h3 style="color: #e74c3c; margin-top: 20px;">🔴 High Priority (${highImportance.length})</h3>
|
||||
${generateChangesList(highImportance)}
|
||||
`;
|
||||
}
|
||||
|
||||
if (mediumImportance.length > 0) {
|
||||
html += `
|
||||
<h3 style="color: #f39c12; margin-top: 20px;">🟡 Medium Priority (${mediumImportance.length})</h3>
|
||||
${generateChangesList(mediumImportance)}
|
||||
`;
|
||||
}
|
||||
|
||||
if (lowImportance.length > 0) {
|
||||
html += `
|
||||
<h3 style="color: #27ae60; margin-top: 20px;">🟢 Low Priority (${lowImportance.length})</h3>
|
||||
${generateChangesList(lowImportance)}
|
||||
`;
|
||||
}
|
||||
|
||||
html += `
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
<a href="${APP_URL}/settings">Manage digest settings</a> |
|
||||
<a href="${APP_URL}/monitors">View all monitors</a>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function generateChangesList(changes: DigestChange[]): string {
|
||||
return `
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${changes.map(c => `
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px 0;">
|
||||
<strong style="color: #333;">${c.monitorName}</strong>
|
||||
<br>
|
||||
<span style="color: #999; font-size: 12px;">${c.monitorUrl}</span>
|
||||
</td>
|
||||
<td style="padding: 10px 0; text-align: right;">
|
||||
<span style="background: ${c.changePercentage > 50 ? '#e74c3c' : c.changePercentage > 10 ? '#f39c12' : '#27ae60'}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">
|
||||
${c.changePercentage.toFixed(1)}% changed
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send digest email to user
|
||||
*/
|
||||
async function sendDigestEmail(user: DigestUser, changes: DigestChange[]): Promise<void> {
|
||||
const subject = changes.length > 0
|
||||
? `📊 ${changes.length} change${changes.length > 1 ? 's' : ''} detected on your monitors`
|
||||
: '📊 Your monitor digest - All quiet!';
|
||||
|
||||
const html = generateDigestHtml(changes, user.digestInterval);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: EMAIL_FROM,
|
||||
to: user.email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
// Update last digest timestamp
|
||||
await db.query(
|
||||
'UPDATE users SET last_digest_at = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
console.log(`[Digest] Sent ${user.digestInterval} digest to ${user.email} with ${changes.length} changes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending digests
|
||||
*/
|
||||
export async function processDigests(interval: 'daily' | 'weekly'): Promise<void> {
|
||||
console.log(`[Digest] Processing ${interval} digests...`);
|
||||
|
||||
const users = await getUsersForDigest(interval);
|
||||
console.log(`[Digest] Found ${users.length} users for ${interval} digest`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const since = user.lastDigestAt || new Date(Date.now() - (interval === 'daily' ? 24 : 168) * 60 * 60 * 1000);
|
||||
const changes = await getChangesForUser(user.id, since);
|
||||
await sendDigestEmail(user, changes);
|
||||
} catch (error) {
|
||||
console.error(`[Digest] Error sending digest to ${user.email}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule digest jobs (call on server start)
|
||||
*/
|
||||
export async function scheduleDigestJobs(): Promise<void> {
|
||||
// Daily digest at 9 AM
|
||||
await digestQueue.add(
|
||||
'daily-digest',
|
||||
{ interval: 'daily' },
|
||||
{
|
||||
jobId: 'daily-digest',
|
||||
repeat: {
|
||||
pattern: '0 9 * * *', // Every day at 9 AM
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Weekly digest on Mondays at 9 AM
|
||||
await digestQueue.add(
|
||||
'weekly-digest',
|
||||
{ interval: 'weekly' },
|
||||
{
|
||||
jobId: 'weekly-digest',
|
||||
repeat: {
|
||||
pattern: '0 9 * * 1', // Every Monday at 9 AM
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Digest] Scheduled daily and weekly digest jobs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start digest worker
|
||||
*/
|
||||
export function startDigestWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
'change-digests',
|
||||
async (job) => {
|
||||
const { interval } = job.data;
|
||||
await processDigests(interval);
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 1,
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`[Digest] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[Digest] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
console.log('[Digest] Worker started');
|
||||
return worker;
|
||||
}
|
||||
|
|
@ -1,128 +1,129 @@
|
|||
import axios, { AxiosResponse } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface FetchResult {
|
||||
html: string;
|
||||
text: string;
|
||||
hash: string;
|
||||
status: number;
|
||||
responseTime: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchPage(
|
||||
url: string,
|
||||
elementSelector?: string
|
||||
): Promise<FetchResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
new URL(url);
|
||||
|
||||
const response: AxiosResponse = await axios.get(url, {
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
Connection: 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
},
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let html = response.data;
|
||||
|
||||
// If element selector is provided, extract only that element
|
||||
if (elementSelector) {
|
||||
const $ = cheerio.load(html);
|
||||
const element = $(elementSelector);
|
||||
|
||||
if (element.length === 0) {
|
||||
throw new Error(`Element not found: ${elementSelector}`);
|
||||
}
|
||||
|
||||
html = element.html() || '';
|
||||
}
|
||||
|
||||
// Extract text content
|
||||
const $ = cheerio.load(html);
|
||||
const text = $.text().trim();
|
||||
|
||||
// Generate hash
|
||||
const hash = crypto.createHash('sha256').update(html).digest('hex');
|
||||
|
||||
return {
|
||||
html,
|
||||
text,
|
||||
hash,
|
||||
status: response.status,
|
||||
responseTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (error.response) {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: error.response.status,
|
||||
responseTime,
|
||||
error: `HTTP ${error.response.status}: ${error.response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: 'Domain not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: 'Request timeout',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextFromHtml(html: string): string {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Remove script and style elements
|
||||
$('script').remove();
|
||||
$('style').remove();
|
||||
|
||||
return $.text().trim();
|
||||
}
|
||||
|
||||
export function calculateHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export interface FetchResult {
|
||||
html: string;
|
||||
text: string;
|
||||
hash: string;
|
||||
status: number;
|
||||
responseTime: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function fetchPage(
|
||||
url: string,
|
||||
elementSelector?: string
|
||||
): Promise<FetchResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
new URL(url);
|
||||
|
||||
const response: AxiosResponse = await axios.get(url, {
|
||||
timeout: 30000,
|
||||
maxRedirects: 5,
|
||||
responseType: 'text', // Force text response to avoid auto-parsing JSON
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
Connection: 'keep-alive',
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
},
|
||||
validateStatus: (status) => status < 500,
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let html = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
|
||||
|
||||
// If element selector is provided, extract only that element
|
||||
if (elementSelector) {
|
||||
const $ = cheerio.load(html);
|
||||
const element = $(elementSelector);
|
||||
|
||||
if (element.length === 0) {
|
||||
throw new Error(`Element not found: ${elementSelector}`);
|
||||
}
|
||||
|
||||
html = element.html() || '';
|
||||
}
|
||||
|
||||
// Extract text content
|
||||
const $ = cheerio.load(html);
|
||||
const text = $.text().trim();
|
||||
|
||||
// Generate hash
|
||||
const hash = crypto.createHash('sha256').update(html).digest('hex');
|
||||
|
||||
return {
|
||||
html,
|
||||
text,
|
||||
hash,
|
||||
status: response.status,
|
||||
responseTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (error.response) {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: error.response.status,
|
||||
responseTime,
|
||||
error: `HTTP ${error.response.status}: ${error.response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: 'Domain not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') {
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: 'Request timeout',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
html: '',
|
||||
text: '',
|
||||
hash: '',
|
||||
status: 0,
|
||||
responseTime,
|
||||
error: error.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTextFromHtml(html: string): string {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Remove script and style elements
|
||||
$('script').remove();
|
||||
$('style').remove();
|
||||
|
||||
return $.text().trim();
|
||||
}
|
||||
|
||||
export function calculateHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import db from '../db';
|
||||
|
||||
interface ImportanceFactors {
|
||||
changePercentage: number; // 0-100
|
||||
keywordMatches: number; // Anzahl wichtiger Keywords
|
||||
isMainContent: boolean; // Haupt- vs. Sidebar-Content
|
||||
isRecurringPattern: boolean; // Wiederkehrendes Muster (z.B. täglich)
|
||||
contentLength: number; // Länge des geänderten Contents
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate importance score for a change (0-100)
|
||||
* Higher scores indicate more significant changes
|
||||
*/
|
||||
export function calculateImportanceScore(factors: ImportanceFactors): number {
|
||||
let score = 0;
|
||||
|
||||
// 1. Change Percentage (max 40 points)
|
||||
// - Small changes (<5%) = low importance
|
||||
// - Medium changes (5-20%) = medium importance
|
||||
// - Large changes (>20%) = high importance
|
||||
if (factors.changePercentage < 5) {
|
||||
score += factors.changePercentage * 2; // 0-10 points
|
||||
} else if (factors.changePercentage < 20) {
|
||||
score += 10 + (factors.changePercentage - 5) * 1.5; // 10-32.5 points
|
||||
} else {
|
||||
score += Math.min(32.5 + (factors.changePercentage - 20) * 0.5, 40); // 32.5-40 points
|
||||
}
|
||||
|
||||
// 2. Keyword matches (max 30 points)
|
||||
// Each keyword match adds importance
|
||||
score += Math.min(factors.keywordMatches * 10, 30);
|
||||
|
||||
// 3. Main content bonus (20 points)
|
||||
// Changes in main content area are more important than sidebar/footer
|
||||
if (factors.isMainContent) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// 4. Content length consideration (max 10 points)
|
||||
// Longer changes tend to be more significant
|
||||
if (factors.contentLength > 500) {
|
||||
score += 10;
|
||||
} else if (factors.contentLength > 100) {
|
||||
score += 5;
|
||||
} else if (factors.contentLength > 50) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
// 5. Recurring pattern penalty (-15 points)
|
||||
// If changes happen at the same time pattern, they're likely automated/less important
|
||||
if (factors.isRecurringPattern) {
|
||||
score -= 15;
|
||||
}
|
||||
|
||||
// Clamp to 0-100
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if monitor has a recurring change pattern
|
||||
* (changes occurring at similar times/intervals)
|
||||
*/
|
||||
export async function detectRecurringPattern(monitorId: string): Promise<boolean> {
|
||||
try {
|
||||
// Get last 10 changes for this monitor
|
||||
const result = await db.query(
|
||||
`SELECT checked_at
|
||||
FROM snapshots
|
||||
WHERE monitor_id = $1 AND has_changes = true
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 10`,
|
||||
[monitorId]
|
||||
);
|
||||
|
||||
if (result.rows.length < 3) {
|
||||
return false; // Not enough data to detect pattern
|
||||
}
|
||||
|
||||
const timestamps = result.rows.map((r: any) => new Date(r.checked_at));
|
||||
|
||||
// Check for same-hour pattern (changes always at similar hour)
|
||||
const hours = timestamps.map((t: Date) => t.getHours());
|
||||
const hourCounts: Record<number, number> = {};
|
||||
hours.forEach((h: number) => {
|
||||
hourCounts[h] = (hourCounts[h] || 0) + 1;
|
||||
});
|
||||
|
||||
// If more than 60% of changes happen at the same hour, it's a pattern
|
||||
const maxHourCount = Math.max(...Object.values(hourCounts));
|
||||
if (maxHourCount / timestamps.length > 0.6) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for regular interval pattern
|
||||
if (timestamps.length >= 3) {
|
||||
const intervals: number[] = [];
|
||||
for (let i = 0; i < timestamps.length - 1; i++) {
|
||||
intervals.push(timestamps[i].getTime() - timestamps[i + 1].getTime());
|
||||
}
|
||||
|
||||
// Check if intervals are consistent (within 10% variance)
|
||||
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const variance = intervals.map(i => Math.abs(i - avgInterval) / avgInterval);
|
||||
const avgVariance = variance.reduce((a, b) => a + b, 0) / variance.length;
|
||||
|
||||
if (avgVariance < 0.1) {
|
||||
return true; // Changes happen at regular intervals
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[Importance] Error detecting pattern:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple heuristic to detect if change is in main content area
|
||||
* Based on common HTML patterns
|
||||
*/
|
||||
export function isMainContentChange(htmlDiff: string): boolean {
|
||||
const mainContentPatterns = [
|
||||
'<main', '</main>',
|
||||
'<article', '</article>',
|
||||
'class="content"', 'class="main"',
|
||||
'id="content"', 'id="main"',
|
||||
'<h1', '<h2', '<h3',
|
||||
'<p>', '</p>'
|
||||
];
|
||||
|
||||
const sidebarPatterns = [
|
||||
'<aside', '</aside>',
|
||||
'<nav', '</nav>',
|
||||
'<footer', '</footer>',
|
||||
'<header', '</header>',
|
||||
'class="sidebar"', 'class="nav"',
|
||||
'class="footer"', 'class="header"'
|
||||
];
|
||||
|
||||
const lowerDiff = htmlDiff.toLowerCase();
|
||||
|
||||
let mainContentScore = 0;
|
||||
let sidebarScore = 0;
|
||||
|
||||
mainContentPatterns.forEach(pattern => {
|
||||
if (lowerDiff.includes(pattern.toLowerCase())) {
|
||||
mainContentScore++;
|
||||
}
|
||||
});
|
||||
|
||||
sidebarPatterns.forEach(pattern => {
|
||||
if (lowerDiff.includes(pattern.toLowerCase())) {
|
||||
sidebarScore++;
|
||||
}
|
||||
});
|
||||
|
||||
return mainContentScore >= sidebarScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get importance level label from score
|
||||
*/
|
||||
export function getImportanceLevel(score: number): 'high' | 'medium' | 'low' {
|
||||
if (score >= 70) return 'high';
|
||||
if (score >= 40) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate importance for a specific change
|
||||
*/
|
||||
export async function calculateChangeImportance(
|
||||
monitorId: string,
|
||||
changePercentage: number,
|
||||
keywordMatches: number,
|
||||
diffContent: string
|
||||
): Promise<number> {
|
||||
const isRecurring = await detectRecurringPattern(monitorId);
|
||||
const isMainContent = isMainContentChange(diffContent);
|
||||
|
||||
return calculateImportanceScore({
|
||||
changePercentage,
|
||||
keywordMatches,
|
||||
isMainContent,
|
||||
isRecurringPattern: isRecurring,
|
||||
contentLength: diffContent.length
|
||||
});
|
||||
}
|
||||
|
|
@ -1,158 +1,216 @@
|
|||
import db from '../db';
|
||||
import { Monitor } from '../types';
|
||||
import { fetchPage } from './fetcher';
|
||||
import {
|
||||
applyIgnoreRules,
|
||||
applyCommonNoiseFilters,
|
||||
compareDiffs,
|
||||
checkKeywords,
|
||||
} from './differ';
|
||||
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
|
||||
|
||||
export async function checkMonitor(monitorId: string): Promise<void> {
|
||||
console.log(`[Monitor] Checking monitor ${monitorId}`);
|
||||
|
||||
try {
|
||||
const monitor = await db.monitors.findById(monitorId);
|
||||
|
||||
if (!monitor) {
|
||||
console.error(`[Monitor] Monitor ${monitorId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.status !== 'active') {
|
||||
console.log(`[Monitor] Monitor ${monitorId} is not active, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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... (1/3)`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// If still failing after retries
|
||||
if (fetchResult.error) {
|
||||
console.error(`[Monitor] Failed to fetch ${monitor.url}: ${fetchResult.error}`);
|
||||
|
||||
// Create error snapshot
|
||||
await db.snapshots.create({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply noise filters
|
||||
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
|
||||
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
|
||||
|
||||
// Get previous snapshot
|
||||
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
|
||||
let changed = false;
|
||||
let changePercentage = 0;
|
||||
|
||||
if (previousSnapshot) {
|
||||
// Apply same filters to previous content for fair comparison
|
||||
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
|
||||
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
|
||||
|
||||
// Compare
|
||||
const diffResult = compareDiffs(previousHtml, processedHtml);
|
||||
changed = diffResult.changed;
|
||||
changePercentage = diffResult.changePercentage;
|
||||
|
||||
console.log(
|
||||
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}`
|
||||
);
|
||||
|
||||
// Check keywords
|
||||
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
|
||||
const keywordMatches = checkKeywords(
|
||||
previousHtml,
|
||||
processedHtml,
|
||||
monitor.keywordRules
|
||||
);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Update monitor
|
||||
await db.monitors.updateLastChecked(monitor.id, changed);
|
||||
|
||||
// Send alert if changed and not first check
|
||||
if (changed && previousSnapshot) {
|
||||
const user = await db.users.findById(monitor.userId);
|
||||
if (user) {
|
||||
await sendChangeAlert(monitor, user, snapshot, changePercentage);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old snapshots (keep last 50)
|
||||
await db.snapshots.deleteOldSnapshots(monitor.id, 50);
|
||||
|
||||
console.log(`[Monitor] Check completed for ${monitor.name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
|
||||
await db.monitors.incrementErrors(monitorId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
|
||||
// This will be implemented when we add the job queue
|
||||
console.log(`[Monitor] Scheduling monitor ${monitor.id} with frequency ${monitor.frequency}m`);
|
||||
}
|
||||
import db from '../db';
|
||||
import { Monitor, Snapshot } from '../types';
|
||||
import { fetchPage } from './fetcher';
|
||||
import {
|
||||
applyIgnoreRules,
|
||||
applyCommonNoiseFilters,
|
||||
compareDiffs,
|
||||
checkKeywords,
|
||||
} from './differ';
|
||||
import { calculateChangeImportance } from './importance';
|
||||
import { sendChangeAlert, sendErrorAlert, sendKeywordAlert } from './alerter';
|
||||
import { generateSimpleSummary, generateAISummary } from './summarizer';
|
||||
|
||||
export interface CheckResult {
|
||||
snapshot: Snapshot;
|
||||
alertSent: boolean;
|
||||
}
|
||||
|
||||
export async function checkMonitor(monitorId: string): Promise<CheckResult | void> {
|
||||
console.log(`[Monitor] Checking monitor ${monitorId}`);
|
||||
|
||||
try {
|
||||
const monitor = await db.monitors.findById(monitorId);
|
||||
|
||||
if (!monitor) {
|
||||
console.error(`[Monitor] Monitor ${monitorId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (monitor.status !== 'active' && monitor.status !== 'error') {
|
||||
console.log(`[Monitor] Monitor ${monitorId} is not active or error, skipping (status: ${monitor.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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... (1/3)`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
fetchResult = await fetchPage(monitor.url, monitor.elementSelector);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { snapshot: failedSnapshot, alertSent: false };
|
||||
}
|
||||
|
||||
// Apply noise filters
|
||||
console.log(`[Monitor] Ignore rules for ${monitor.name}:`, JSON.stringify(monitor.ignoreRules));
|
||||
let processedHtml = applyCommonNoiseFilters(fetchResult.html);
|
||||
processedHtml = applyIgnoreRules(processedHtml, monitor.ignoreRules);
|
||||
|
||||
// Get previous snapshot
|
||||
const previousSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
||||
|
||||
let changed = false;
|
||||
let changePercentage = 0;
|
||||
let diffResult: ReturnType<typeof compareDiffs> | undefined;
|
||||
|
||||
if (previousSnapshot) {
|
||||
// Apply same filters to previous content for fair comparison
|
||||
let previousHtml = applyCommonNoiseFilters(previousSnapshot.htmlContent);
|
||||
previousHtml = applyIgnoreRules(previousHtml, monitor.ignoreRules);
|
||||
|
||||
// Compare
|
||||
diffResult = compareDiffs(previousHtml, processedHtml);
|
||||
changed = diffResult.changed;
|
||||
changePercentage = diffResult.changePercentage;
|
||||
|
||||
console.log(
|
||||
`[Monitor] ${monitor.name}: Changed=${changed}, Change%=${changePercentage.toFixed(2)}, Additions=${diffResult.additions}, Deletions=${diffResult.deletions}`
|
||||
);
|
||||
|
||||
// Check keywords
|
||||
if (monitor.keywordRules && monitor.keywordRules.length > 0) {
|
||||
const keywordMatches = checkKeywords(
|
||||
previousHtml,
|
||||
processedHtml,
|
||||
monitor.keywordRules
|
||||
);
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
// Generate human-readable summary (Hybrid approach)
|
||||
let summary: string | undefined;
|
||||
|
||||
if (changed && previousSnapshot && diffResult) {
|
||||
// Hybrid logic: AI for changes (≥5%), simple for very small changes
|
||||
if (changePercentage >= 5) {
|
||||
console.log(`[Monitor] Change (${changePercentage}%), using AI summary`);
|
||||
try {
|
||||
summary = await generateAISummary(diffResult.diff, changePercentage);
|
||||
} catch (error) {
|
||||
console.error('[Monitor] AI summary failed, falling back to simple summary:', error);
|
||||
summary = generateSimpleSummary(
|
||||
diffResult.diff,
|
||||
previousSnapshot.htmlContent,
|
||||
fetchResult.html
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`[Monitor] Small change (${changePercentage}%), using simple summary`);
|
||||
summary = generateSimpleSummary(
|
||||
diffResult.diff,
|
||||
previousSnapshot.htmlContent,
|
||||
fetchResult.html
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
const snapshot = await db.snapshots.create({
|
||||
monitorId: monitor.id,
|
||||
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);
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error(`[Monitor] Failed to cleanup snapshots for ${monitor.id}:`, cleanupError);
|
||||
}
|
||||
|
||||
console.log(`[Monitor] Check completed for ${monitor.name}`);
|
||||
return { snapshot, alertSent: changed && !!previousSnapshot };
|
||||
} catch (error) {
|
||||
console.error(`[Monitor] Error checking monitor ${monitorId}:`, error);
|
||||
await db.monitors.incrementErrors(monitorId);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export scheduler functions for backward compatibility
|
||||
export { scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from './scheduler';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import { Queue, Worker, QueueEvents } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import { checkMonitor } from './monitor';
|
||||
import { Monitor } from '../db';
|
||||
|
||||
// Redis connection
|
||||
const redisConnection = new Redis(process.env.REDIS_URL || 'redis://localhost:6380', {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
// Monitor check queue
|
||||
export const monitorQueue = new Queue('monitor-checks', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100, // Keep last 100 completed jobs
|
||||
removeOnFail: 50, // Keep last 50 failed jobs
|
||||
attempts: 3, // Retry failed jobs 3 times
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000, // Start with 5 second delay
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue events for monitoring
|
||||
const queueEvents = new QueueEvents('monitor-checks', { connection: redisConnection });
|
||||
|
||||
queueEvents.on('completed', ({ jobId }) => {
|
||||
console.log(`[Scheduler] Job ${jobId} completed`);
|
||||
});
|
||||
|
||||
queueEvents.on('failed', ({ jobId, failedReason }) => {
|
||||
console.error(`[Scheduler] Job ${jobId} failed:`, failedReason);
|
||||
});
|
||||
|
||||
/**
|
||||
* Schedule a monitor for recurring checks
|
||||
*/
|
||||
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
|
||||
if (monitor.status !== 'active') {
|
||||
console.log(`[Scheduler] Skipping inactive monitor ${monitor.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = `monitor-${monitor.id}`;
|
||||
|
||||
try {
|
||||
// Remove existing job if it exists
|
||||
await unscheduleMonitor(monitor.id);
|
||||
|
||||
// Add new recurring job
|
||||
await monitorQueue.add(
|
||||
'check-monitor',
|
||||
{
|
||||
monitorId: monitor.id,
|
||||
url: monitor.url,
|
||||
name: monitor.name,
|
||||
},
|
||||
{
|
||||
jobId,
|
||||
repeat: {
|
||||
every: monitor.frequency * 60 * 1000, // Convert minutes to milliseconds
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[Scheduler] Monitor ${monitor.id} scheduled (frequency: ${monitor.frequency} min)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to schedule monitor ${monitor.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a monitor from the schedule
|
||||
*/
|
||||
export async function unscheduleMonitor(monitorId: string): Promise<void> {
|
||||
const jobId = `monitor-${monitorId}`;
|
||||
|
||||
try {
|
||||
// Get all repeatable jobs
|
||||
const repeatableJobs = await monitorQueue.getRepeatableJobs();
|
||||
|
||||
// Find and remove the job for this monitor
|
||||
const job = repeatableJobs.find((j) => j.id === jobId);
|
||||
if (job && job.key) {
|
||||
await monitorQueue.removeRepeatableByKey(job.key);
|
||||
console.log(`[Scheduler] Monitor ${monitorId} unscheduled`);
|
||||
}
|
||||
|
||||
// Also remove any pending jobs with this ID
|
||||
const jobs = await monitorQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const j of jobs) {
|
||||
if (j.id === jobId) {
|
||||
await j.remove();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Scheduler] Failed to unschedule monitor ${monitorId}:`, error);
|
||||
// Don't throw - unscheduling is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule a monitor (useful when frequency changes)
|
||||
*/
|
||||
export async function rescheduleMonitor(monitor: Monitor): Promise<void> {
|
||||
console.log(`[Scheduler] Rescheduling monitor ${monitor.id}`);
|
||||
await unscheduleMonitor(monitor.id);
|
||||
await scheduleMonitor(monitor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the worker to process monitor checks
|
||||
*/
|
||||
export function startWorker(): Worker {
|
||||
const worker = new Worker(
|
||||
'monitor-checks',
|
||||
async (job) => {
|
||||
const { monitorId } = job.data;
|
||||
console.log(`[Worker] Processing check for monitor ${monitorId}`);
|
||||
|
||||
try {
|
||||
await checkMonitor(monitorId);
|
||||
console.log(`[Worker] Successfully checked monitor ${monitorId}`);
|
||||
} catch (error) {
|
||||
console.error(`[Worker] Error checking monitor ${monitorId}:`, error);
|
||||
throw error; // Re-throw to mark job as failed
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: redisConnection,
|
||||
concurrency: 5, // Process up to 5 monitors concurrently
|
||||
}
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`[Worker] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`[Worker] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', (err) => {
|
||||
console.error('[Worker] Worker error:', err);
|
||||
});
|
||||
|
||||
console.log('[Worker] Monitor check worker started');
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the scheduler
|
||||
*/
|
||||
export async function shutdownScheduler(): Promise<void> {
|
||||
console.log('[Scheduler] Shutting down...');
|
||||
|
||||
try {
|
||||
await monitorQueue.close();
|
||||
await queueEvents.close();
|
||||
await redisConnection.quit();
|
||||
console.log('[Scheduler] Shutdown complete');
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Error during shutdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler stats for monitoring
|
||||
*/
|
||||
export async function getSchedulerStats() {
|
||||
try {
|
||||
const [waiting, active, completed, failed, delayed, repeatableJobs] = await Promise.all([
|
||||
monitorQueue.getWaitingCount(),
|
||||
monitorQueue.getActiveCount(),
|
||||
monitorQueue.getCompletedCount(),
|
||||
monitorQueue.getFailedCount(),
|
||||
monitorQueue.getDelayedCount(),
|
||||
monitorQueue.getRepeatableJobs(),
|
||||
]);
|
||||
|
||||
return {
|
||||
waiting,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
delayed,
|
||||
scheduled: repeatableJobs.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Error getting stats:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import { Change } from 'diff';
|
||||
import * as cheerio from 'cheerio';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
/**
|
||||
* Generate a simple human-readable summary of changes
|
||||
* Uses HTML parsing to count added/removed elements
|
||||
*
|
||||
* Example output: "3 text blocks changed, 2 new links added, 1 image removed"
|
||||
*/
|
||||
export function generateSimpleSummary(
|
||||
diff: Change[],
|
||||
htmlOld: string,
|
||||
htmlNew: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Extract text previews from diff
|
||||
const textPreviews = extractTextPreviews(diff);
|
||||
|
||||
// Count changed text blocks from diff
|
||||
const changedTextBlocks = countChangedTextNodes(diff);
|
||||
if (changedTextBlocks > 0) {
|
||||
parts.push(`${changedTextBlocks} text block${changedTextBlocks > 1 ? 's' : ''} changed`);
|
||||
}
|
||||
|
||||
// Parse HTML to count structural changes
|
||||
const addedLinks = countAddedElements(htmlOld, htmlNew, 'a');
|
||||
const removedLinks = countRemovedElements(htmlOld, htmlNew, 'a');
|
||||
const addedImages = countAddedElements(htmlOld, htmlNew, 'img');
|
||||
const removedImages = countRemovedElements(htmlOld, htmlNew, 'img');
|
||||
const addedTables = countAddedElements(htmlOld, htmlNew, 'table');
|
||||
const removedTables = countRemovedElements(htmlOld, htmlNew, 'table');
|
||||
const addedLists = countAddedElements(htmlOld, htmlNew, 'ul') + countAddedElements(htmlOld, htmlNew, 'ol');
|
||||
const removedLists = countRemovedElements(htmlOld, htmlNew, 'ul') + countRemovedElements(htmlOld, htmlNew, 'ol');
|
||||
|
||||
// Add links
|
||||
if (addedLinks > 0) {
|
||||
parts.push(`${addedLinks} new link${addedLinks > 1 ? 's' : ''} added`);
|
||||
}
|
||||
if (removedLinks > 0) {
|
||||
parts.push(`${removedLinks} link${removedLinks > 1 ? 's' : ''} removed`);
|
||||
}
|
||||
|
||||
// Add images
|
||||
if (addedImages > 0) {
|
||||
parts.push(`${addedImages} new image${addedImages > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (removedImages > 0) {
|
||||
parts.push(`${removedImages} image${removedImages > 1 ? 's' : ''} removed`);
|
||||
}
|
||||
|
||||
// Add tables
|
||||
if (addedTables > 0) {
|
||||
parts.push(`${addedTables} new table${addedTables > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (removedTables > 0) {
|
||||
parts.push(`${removedTables} table${removedTables > 1 ? 's' : ''} removed`);
|
||||
}
|
||||
|
||||
// Add lists
|
||||
if (addedLists > 0) {
|
||||
parts.push(`${addedLists} new list${addedLists > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (removedLists > 0) {
|
||||
parts.push(`${removedLists} list${removedLists > 1 ? 's' : ''} removed`);
|
||||
}
|
||||
|
||||
// If no specific changes found, return generic message
|
||||
if (parts.length === 0 && textPreviews.length === 0) {
|
||||
return 'Content updated';
|
||||
}
|
||||
|
||||
// Build summary with text previews
|
||||
let summary = parts.join(', ');
|
||||
|
||||
// Add text preview if available
|
||||
if (textPreviews.length > 0) {
|
||||
const previewText = textPreviews.slice(0, 2).join(' → ');
|
||||
if (summary) {
|
||||
summary += `. Changed: "${previewText}"`;
|
||||
} else {
|
||||
summary = `Text changed: "${previewText}"`;
|
||||
}
|
||||
}
|
||||
|
||||
return summary.charAt(0).toUpperCase() + summary.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short text previews from diff (focus on visible text, ignore code)
|
||||
*/
|
||||
function extractTextPreviews(diff: Change[]): string[] {
|
||||
const previews: string[] = [];
|
||||
const maxPreviewLength = 50;
|
||||
|
||||
for (const part of diff) {
|
||||
if (part.added || part.removed) {
|
||||
// Skip if it looks like CSS, JavaScript, or technical code
|
||||
if (looksLikeCode(part.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract visible text (strip HTML tags)
|
||||
const text = extractVisibleText(part.value);
|
||||
|
||||
if (text.length > 5) { // Only include meaningful text
|
||||
const truncated = text.length > maxPreviewLength
|
||||
? text.substring(0, maxPreviewLength) + '...'
|
||||
: text;
|
||||
|
||||
const prefix = part.added ? '+' : '-';
|
||||
previews.push(`${prefix}${truncated}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return previews.slice(0, 4); // Limit to 4 previews
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text looks like code (CSS, JS, etc.)
|
||||
*/
|
||||
function looksLikeCode(text: string): boolean {
|
||||
const codePatterns = [
|
||||
/^\s*[\.\#@]\w+\s*\{/m, // CSS selectors (.class {, #id {, @media {)
|
||||
/{\s*[a-z-]+\s*:\s*[^}]+}/i, // CSS properties ({ color: red; })
|
||||
/function\s*\(/, // JavaScript function
|
||||
/\bconst\s+\w+\s*=/, // JS const declaration
|
||||
/\bvar\s+\w+\s*=/, // JS var declaration
|
||||
/\blet\s+\w+\s*=/, // JS let declaration
|
||||
/^\s*<script/im, // Script tags
|
||||
/^\s*<style/im, // Style tags
|
||||
/[a-z-]+\s*:\s*[#\d]+[a-z]+\s*;/i, // CSS property: value;
|
||||
];
|
||||
|
||||
return codePatterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visible text from HTML, focusing on meaningful content
|
||||
*/
|
||||
function extractVisibleText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '') // Remove scripts
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '') // Remove styles
|
||||
.replace(/<[^>]*>/g, ' ') // Remove HTML tags
|
||||
.replace(/ /g, ' ') // Replace
|
||||
.replace(/&[a-z]+;/gi, ' ') // Remove other HTML entities
|
||||
.replace(/\{[^}]*\}/g, '') // Remove CSS blocks { }
|
||||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count changed text nodes from diff
|
||||
*/
|
||||
function countChangedTextNodes(diff: Change[]): number {
|
||||
let count = 0;
|
||||
|
||||
diff.forEach((part) => {
|
||||
if (part.added || part.removed) {
|
||||
// Count non-empty lines as text blocks
|
||||
const lines = part.value.split('\n').filter(line => line.trim().length > 0);
|
||||
count += lines.length;
|
||||
}
|
||||
});
|
||||
|
||||
// Divide by 2 because additions and removals are counted separately
|
||||
// but represent the same change
|
||||
return Math.ceil(count / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count elements added between old and new HTML
|
||||
*/
|
||||
function countAddedElements(htmlOld: string, htmlNew: string, tag: string): number {
|
||||
try {
|
||||
const $old = cheerio.load(htmlOld);
|
||||
const $new = cheerio.load(htmlNew);
|
||||
|
||||
const oldCount = $old(tag).length;
|
||||
const newCount = $new(tag).length;
|
||||
|
||||
return Math.max(0, newCount - oldCount);
|
||||
} catch (error) {
|
||||
console.error(`[Summarizer] Error counting added ${tag}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count elements removed between old and new HTML
|
||||
*/
|
||||
function countRemovedElements(htmlOld: string, htmlNew: string, tag: string): number {
|
||||
try {
|
||||
const $old = cheerio.load(htmlOld);
|
||||
const $new = cheerio.load(htmlNew);
|
||||
|
||||
const oldCount = $old(tag).length;
|
||||
const newCount = $new(tag).length;
|
||||
|
||||
return Math.max(0, oldCount - newCount);
|
||||
} catch (error) {
|
||||
console.error(`[Summarizer] Error counting removed ${tag}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI-powered summary for large changes (≥10%)
|
||||
* Uses OpenAI API (GPT-4o-mini for cost-efficiency)
|
||||
*/
|
||||
export async function generateAISummary(
|
||||
diff: Change[],
|
||||
changePercentage: number
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Check if API key is configured
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
console.warn('[Summarizer] OPENAI_API_KEY not configured, falling back to simple summary');
|
||||
throw new Error('OPENAI_API_KEY not configured');
|
||||
}
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// Format diff for AI (reduce token count)
|
||||
const formattedDiff = formatDiffForAI(diff);
|
||||
|
||||
const prompt = `Analyze this website change and create a concise summary for non-programmers.
|
||||
Focus on IMPORTANT changes only. Medium detail level.
|
||||
|
||||
Change percentage: ${changePercentage.toFixed(2)}%
|
||||
|
||||
Diff:
|
||||
${formattedDiff}
|
||||
|
||||
Format: "Section name: What changed. Details if important."
|
||||
Example: "Pricing section updated: 3 prices increased. 2 new product links in footer."
|
||||
Keep it under 100 words. Be specific about what changed, not how.`;
|
||||
|
||||
const completion = await client.chat.completions.create({
|
||||
model: 'gpt-4o-mini', // Fastest, cheapest
|
||||
max_tokens: 200,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.3, // Low temperature for consistent, factual summaries
|
||||
});
|
||||
|
||||
// Extract text from response
|
||||
const summary = completion.choices[0]?.message?.content || 'Content updated';
|
||||
|
||||
console.log('[Summarizer] AI summary generated:', summary);
|
||||
return summary.trim();
|
||||
} catch (error) {
|
||||
console.error('[Summarizer] AI summary failed:', error);
|
||||
throw error; // Re-throw to allow fallback to simple summary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format diff for AI to reduce token count
|
||||
* Extracts only added/removed content, limits to ~1000 characters
|
||||
*/
|
||||
function formatDiffForAI(diff: Change[]): string {
|
||||
const lines: string[] = [];
|
||||
let charCount = 0;
|
||||
const maxChars = 1000;
|
||||
|
||||
for (const part of diff) {
|
||||
if (charCount >= maxChars) break;
|
||||
|
||||
if (part.added) {
|
||||
const prefix = '+ ';
|
||||
const content = part.value.trim().substring(0, 200); // Limit each chunk
|
||||
lines.push(prefix + content);
|
||||
charCount += prefix.length + content.length;
|
||||
} else if (part.removed) {
|
||||
const prefix = '- ';
|
||||
const content = part.value.trim().substring(0, 200);
|
||||
lines.push(prefix + content);
|
||||
charCount += prefix.length + content.length;
|
||||
}
|
||||
// Skip unchanged parts to save tokens
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
|
@ -1,101 +1,110 @@
|
|||
export type UserPlan = 'free' | 'pro' | 'business' | 'enterprise';
|
||||
|
||||
export type MonitorStatus = 'active' | 'paused' | 'error';
|
||||
|
||||
export type MonitorFrequency = 1 | 5 | 30 | 60 | 360 | 1440; // minutes
|
||||
|
||||
export type AlertType = 'change' | 'error' | 'keyword';
|
||||
|
||||
export type AlertChannel = 'email' | 'slack' | 'webhook';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
plan: UserPlan;
|
||||
stripeCustomerId?: string;
|
||||
createdAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
export interface IgnoreRule {
|
||||
type: 'css' | 'regex' | 'text';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeywordRule {
|
||||
keyword: string;
|
||||
type: 'appears' | 'disappears' | 'count';
|
||||
threshold?: number;
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
frequency: MonitorFrequency;
|
||||
status: MonitorStatus;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
lastCheckedAt?: Date;
|
||||
lastChangedAt?: Date;
|
||||
consecutiveErrors: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
htmlContent: string;
|
||||
textContent: string;
|
||||
contentHash: string;
|
||||
screenshotUrl?: string;
|
||||
httpStatus: number;
|
||||
responseTime: number;
|
||||
changed: boolean;
|
||||
changePercentage?: number;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
snapshotId: string;
|
||||
userId: string;
|
||||
type: AlertType;
|
||||
title: string;
|
||||
summary?: string;
|
||||
channels: AlertChannel[];
|
||||
deliveredAt?: Date;
|
||||
readAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
plan: UserPlan;
|
||||
}
|
||||
|
||||
export interface CreateMonitorInput {
|
||||
url: string;
|
||||
name?: string;
|
||||
frequency: MonitorFrequency;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
}
|
||||
|
||||
export interface UpdateMonitorInput {
|
||||
name?: string;
|
||||
frequency?: MonitorFrequency;
|
||||
status?: MonitorStatus;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
}
|
||||
export type UserPlan = 'free' | 'pro' | 'business' | 'enterprise';
|
||||
|
||||
export type MonitorStatus = 'active' | 'paused' | 'error';
|
||||
|
||||
export type MonitorFrequency = 1 | 5 | 30 | 60 | 360 | 1440; // minutes
|
||||
|
||||
export type AlertType = 'change' | 'error' | 'keyword';
|
||||
|
||||
export type AlertChannel = 'email' | 'slack' | 'webhook';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
plan: UserPlan;
|
||||
stripeCustomerId?: string;
|
||||
emailVerified?: boolean;
|
||||
emailVerifiedAt?: Date;
|
||||
emailEnabled?: boolean;
|
||||
webhookUrl?: string;
|
||||
webhookEnabled?: boolean;
|
||||
slackWebhookUrl?: string;
|
||||
slackEnabled?: boolean;
|
||||
createdAt: Date;
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
export interface IgnoreRule {
|
||||
type: 'css' | 'regex' | 'text';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeywordRule {
|
||||
keyword: string;
|
||||
type: 'appears' | 'disappears' | 'count';
|
||||
threshold?: number;
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface Monitor {
|
||||
id: string;
|
||||
userId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
frequency: MonitorFrequency;
|
||||
status: MonitorStatus;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
lastCheckedAt?: Date;
|
||||
lastChangedAt?: Date;
|
||||
consecutiveErrors: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
htmlContent: string;
|
||||
textContent: string;
|
||||
contentHash: string;
|
||||
screenshotUrl?: string;
|
||||
httpStatus: number;
|
||||
responseTime: number;
|
||||
changed: boolean;
|
||||
changePercentage?: number;
|
||||
importanceScore?: number;
|
||||
summary?: string;
|
||||
errorMessage?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
monitorId: string;
|
||||
snapshotId: string;
|
||||
userId: string;
|
||||
type: AlertType;
|
||||
title: string;
|
||||
summary?: string;
|
||||
channels: AlertChannel[];
|
||||
deliveredAt?: Date;
|
||||
readAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
plan: UserPlan;
|
||||
}
|
||||
|
||||
export interface CreateMonitorInput {
|
||||
url: string;
|
||||
name?: string;
|
||||
frequency: MonitorFrequency;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
}
|
||||
|
||||
export interface UpdateMonitorInput {
|
||||
name?: string;
|
||||
frequency?: MonitorFrequency;
|
||||
status?: MonitorStatus;
|
||||
elementSelector?: string;
|
||||
ignoreRules?: IgnoreRule[];
|
||||
keywordRules?: KeywordRule[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,64 +1,96 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWTPayload, User } from '../types';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export function generateToken(user: User): string {
|
||||
const payload: JWTPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWTPayload, User } from '../types';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export async function comparePassword(
|
||||
password: string,
|
||||
hash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export function generateToken(user: User): string {
|
||||
const payload: JWTPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
plan: user.plan,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN as jwt.SignOptions['expiresIn'] });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
return jwt.verify(token, JWT_SECRET) as JWTPayload;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
export function generatePasswordResetToken(email: string): string {
|
||||
return jwt.sign({ email, type: 'password-reset' }, JWT_SECRET, { expiresIn: '1h' });
|
||||
}
|
||||
|
||||
export function verifyPasswordResetToken(token: string): { email: string } {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
|
||||
if (decoded.type !== 'password-reset') {
|
||||
throw new Error('Invalid token type');
|
||||
}
|
||||
return { email: decoded.email };
|
||||
} catch (error) {
|
||||
throw new Error('Invalid or expired reset token');
|
||||
}
|
||||
}
|
||||
|
||||
export function generateEmailVerificationToken(email: string): string {
|
||||
return jwt.sign({ email, type: 'email-verification' }, JWT_SECRET, { expiresIn: '24h' });
|
||||
}
|
||||
|
||||
export function verifyEmailVerificationToken(token: string): { email: string } {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { email: string; type: string };
|
||||
if (decoded.type !== 'email-verification') {
|
||||
throw new Error('Invalid token type');
|
||||
}
|
||||
return { email: decoded.email };
|
||||
} catch (error) {
|
||||
throw new Error('Invalid or expired verification token');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -0,0 +1 @@
|
|||
/c/Users/timo/Documents/Websites/website-monitor/backend
|
||||
|
|
@ -1,24 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
|
@ -1,34 +1,34 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: website-monitor-postgres
|
||||
environment:
|
||||
POSTGRES_DB: website_monitor
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: admin123
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U admin"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: website-monitor-redis
|
||||
ports:
|
||||
- "6380:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: website-monitor-postgres
|
||||
environment:
|
||||
POSTGRES_DB: website_monitor
|
||||
POSTGRES_USER: admin
|
||||
POSTGRES_PASSWORD: admin123
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U admin"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: website-monitor-redis
|
||||
ports:
|
||||
- "6380:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,801 @@
|
|||
Website Monitor - Umfassende Analyse & Verbesserungsplan
|
||||
|
||||
📊 Projekt-Status Übersicht
|
||||
|
||||
Implementierungsstatus nach Bereich
|
||||
┌─────────────────────┬─────────┬───────────┬───────────────────────────┐
|
||||
│ Bereich │ Status │ Qualität │ Kritische Issues │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Landing Page │ ✅ 100% │ Exzellent │ Keine │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Authentication │ ✅ 100% │ Gut │ Password Reset fehlt │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Dashboard │ ✅ 100% │ Gut │ Keine │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Monitors Management │ ✅ 100% │ Exzellent │ Keyword UI fehlt │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Monitor History │ ✅ 100% │ Gut │ Keine │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Snapshot Details │ ✅ 100% │ Exzellent │ Keine │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Analytics │ ⚠️ 60% │ Basic │ Keine Trends/Zeitbereiche │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Incidents │ ⚠️ 60% │ Basic │ Kein Resolution Tracking │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Settings │ ❌ 20% │ Stub │ Komplett nicht funktional │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Backend Core │ ✅ 95% │ Exzellent │ Job Scheduling fehlt │
|
||||
├─────────────────────┼─────────┼───────────┼───────────────────────────┤
|
||||
│ Change Detection │ ✅ 100% │ Exzellent │ Funktioniert! │
|
||||
└─────────────────────┴─────────┴───────────┴───────────────────────────┘
|
||||
---
|
||||
🚨 KRITISCHER BLOCKER (Muss vor Launch behoben werden)
|
||||
|
||||
Problem: Keine automatische Überwachung
|
||||
|
||||
Dateien:
|
||||
- backend/src/services/monitor.ts (Zeile 168-171) - scheduleMonitor() ist nur ein Stub
|
||||
- backend/src/index.ts - Queue-Initialisierung fehlt
|
||||
- backend/src/routes/monitors.ts - Ruft scheduleMonitor() auf, aber die tut nichts
|
||||
|
||||
Aktueller Stand:
|
||||
export async function scheduleMonitor(monitor: Monitor): Promise<void> {
|
||||
// This will be implemented when we add the job queue
|
||||
console.log(`[Monitor] Scheduling monitor ${monitor.id}...`);
|
||||
}
|
||||
|
||||
Auswirkung:
|
||||
- ❌ Monitors prüfen NICHT automatisch im Hintergrund
|
||||
- ❌ Nutzer müssen manuell "Check Now" klicken
|
||||
- ❌ Das komplette Wertversprechen ("I watch pages so you don't have to") funktioniert nicht
|
||||
- ✅ Manuelle Checks über API funktionieren (aber das ist nicht das Produkt)
|
||||
|
||||
Was fehlt:
|
||||
1. Bull Queue Worker implementieren
|
||||
2. Redis-Verbindung initialisieren
|
||||
3. Recurring Jobs für jeden Monitor erstellen
|
||||
4. Job-Processor der checkMonitor() aufruft
|
||||
5. Job-Cleanup bei Monitor-Löschung/Pause
|
||||
6. Job-Aktualisierung bei Frequency-Änderung
|
||||
|
||||
---
|
||||
✅ Was tatsächlich FUNKTIONIERT (Change Detection Analyse)
|
||||
|
||||
Der Change Detection Algorithmus ist exzellent implementiert:
|
||||
|
||||
1. Multi-Layer Noise Filtering:
|
||||
- ✅ 20+ Timestamp-Regex-Patterns (ISO, Unix, relative Zeiten)
|
||||
- ✅ Cookie Banner via CSS Selektoren (20+ Patterns)
|
||||
- ✅ Script/Style Tag Entfernung
|
||||
- ✅ Custom Ignore Rules (CSS, Regex, Text)
|
||||
|
||||
2. Diff-Vergleich:
|
||||
- ✅ Verwendet diff Library mit diffLines()
|
||||
- ✅ Berechnet Change Percentage korrekt
|
||||
- ✅ Zählt Additions/Deletions
|
||||
- ✅ Severity Classification (major > 50%, medium > 10%, minor)
|
||||
|
||||
3. Keyword Detection:
|
||||
- ✅ 3 Rule Types: appears, disappears, count
|
||||
- ✅ Case-sensitive Support
|
||||
- ✅ Threshold-basierte Triggering
|
||||
- ✅ Detaillierte Match-Info
|
||||
|
||||
4. Error Handling:
|
||||
- ✅ 3-Retry Logic mit Backoff
|
||||
- ✅ Consecutive Error Tracking
|
||||
- ✅ Automatische Error Alerts (ab 2 Fehlern)
|
||||
|
||||
Fazit: Der Core-Algorithmus ist produktionsreif und arbeitet zuverlässig.
|
||||
|
||||
---
|
||||
📝 Detaillierte Feature-Analyse
|
||||
|
||||
1. Landing Page (frontend/app/page.tsx)
|
||||
|
||||
Status: ✅ Vollständig implementiert
|
||||
|
||||
Vorhandene Features:
|
||||
- Hero Section mit CTAs
|
||||
- Feature-Highlights (3 Differenzierungsmerkmale)
|
||||
- "How it works" Steps
|
||||
- 3-Tier Pricing (Starter/Pro/Enterprise)
|
||||
- FAQ Accordion
|
||||
- Responsive Design
|
||||
|
||||
Kleinere Issues:
|
||||
- Demo-Video Link ist Platzhalter
|
||||
- Pricing Buttons führen nicht zur Checkout-Flow
|
||||
- "10,000+ developers" ist Hardcoded
|
||||
|
||||
---
|
||||
2. Authentication (frontend/app/login, frontend/app/register)
|
||||
|
||||
Status: ✅ Vollständig, ⚠️ Features fehlen
|
||||
|
||||
Vorhanden:
|
||||
- Login/Register mit Validation
|
||||
- JWT Token Management
|
||||
- Auto-Redirect bei Authentication
|
||||
- Error Handling
|
||||
- Loading States
|
||||
|
||||
Fehlt:
|
||||
- ❌ Password Reset/Recovery Flow
|
||||
- ❌ Email Verification
|
||||
- ❌ "Remember Me" Funktionalität
|
||||
- ❌ 2FA Support
|
||||
- ❌ Social Auth (Google, GitHub)
|
||||
|
||||
---
|
||||
3. Dashboard (frontend/app/dashboard/page.tsx)
|
||||
|
||||
Status: ✅ Gut implementiert
|
||||
|
||||
Features:
|
||||
- 4 Stat Cards (Total, Active, Errors, Recent Changes)
|
||||
- Recent Monitors List (Top 5)
|
||||
- Quick Action Buttons
|
||||
- Status Indicators
|
||||
|
||||
Verbesserungspotenzial:
|
||||
- Keine Pagination (nur 5 Monitors)
|
||||
- Keine Charts/Visualisierungen
|
||||
- Keine Echtzeit-Updates
|
||||
- Keine historischen Trends
|
||||
|
||||
---
|
||||
4. Monitors Management (frontend/app/monitors/page.tsx)
|
||||
|
||||
Status: ✅ Exzellent implementiert
|
||||
|
||||
Starke Features:
|
||||
- Grid/List View Toggle
|
||||
- Filter Tabs (All, Active, Error)
|
||||
- Inline Create/Edit Form
|
||||
- Frequency Presets (5min bis 24h)
|
||||
- Ignore Content Presets (Timestamps, Cookies, etc.)
|
||||
- Custom CSS Selector Support
|
||||
- Check Now, Edit, Delete Actions
|
||||
- Konfirmations-Dialoge
|
||||
|
||||
Fehlt:
|
||||
- ❌ Keyword Rules UI (Backend unterstützt es, aber kein UI!)
|
||||
- ❌ Visual Element Picker
|
||||
- ❌ Bulk Actions (mehrere Monitors gleichzeitig)
|
||||
- ❌ Tags/Gruppierung
|
||||
- ❌ Keyboard Shortcuts
|
||||
|
||||
---
|
||||
5. Monitor History & Snapshots
|
||||
|
||||
Status: ✅ Sehr gut implementiert
|
||||
|
||||
Features:
|
||||
- Timeline mit allen Checks
|
||||
- Change/No Change/Error Badges
|
||||
- HTTP Status + Response Time
|
||||
- Change Percentage
|
||||
- Error Messages
|
||||
- Diff Viewer mit Split-View (react-diff-viewer-continued)
|
||||
|
||||
Verbesserungspotenzial:
|
||||
- Keine Pagination (lädt alle 50 Snapshots auf einmal)
|
||||
- Kein Zeitbereich-Filter
|
||||
- Kein Export (PDF, CSV)
|
||||
- Keine Screenshot-Vergleiche
|
||||
|
||||
---
|
||||
6. Analytics (frontend/app/analytics/page.tsx)
|
||||
|
||||
Status: ⚠️ 60% - Basic Stats nur
|
||||
|
||||
Vorhanden:
|
||||
- Total Monitors, Uptime Rate, Error Rate
|
||||
- Monitor Status Distribution (Donut Chart)
|
||||
- Check Frequency Distribution (Bar Charts)
|
||||
|
||||
Kritisch fehlend:
|
||||
- ❌ Zeitbereich-Auswahl (7d, 30d, 90d)
|
||||
- ❌ Trend-Charts (Change Frequency über Zeit)
|
||||
- ❌ Response Time Trends
|
||||
- ❌ Historische Vergleiche
|
||||
- ❌ Per-Monitor Analytics
|
||||
- ❌ Export-Funktionalität
|
||||
|
||||
---
|
||||
7. Incidents (frontend/app/incidents/page.tsx)
|
||||
|
||||
Status: ⚠️ 60% - Sehr basic
|
||||
|
||||
Vorhanden:
|
||||
- Liste von Errors + Changes
|
||||
- Type Badges
|
||||
- View Details Links
|
||||
|
||||
Fehlt:
|
||||
- ❌ Incident Grouping (gleicher Monitor, gleicher Tag)
|
||||
- ❌ Resolution Tracking (Mark as resolved)
|
||||
- ❌ Severity Levels
|
||||
- ❌ Incident Timeline
|
||||
- ❌ Filter nach Datum/Typ
|
||||
- ❌ Alert Delivery Status
|
||||
|
||||
---
|
||||
8. Settings (frontend/app/settings/page.tsx)
|
||||
|
||||
Status: ❌ 20% - Nur UI Mockup
|
||||
|
||||
Problem: Alle Buttons sind nicht-funktional, keine Backend-Integration!
|
||||
|
||||
Was fehlt:
|
||||
- ❌ Change Password Flow
|
||||
- ❌ Email Notification Preferences
|
||||
- ❌ Slack Integration Setup
|
||||
- ❌ Webhook Configuration
|
||||
- ❌ Billing Management (Stripe Portal Link)
|
||||
- ❌ Account Deletion mit Confirmation
|
||||
- ❌ Plan Management
|
||||
|
||||
---
|
||||
🏗️ Backend Architektur-Analys
|
||||
|
||||
Datenbankschema (backend/src/db/schema.sql)
|
||||
|
||||
Status: ✅ Gut durchdacht
|
||||
|
||||
Tabellen:
|
||||
- users - Email, password_hash, plan, stripe_customer_id
|
||||
- monitors - URL, frequency, rules (JSONB), status tracking
|
||||
- snapshots - HTML, text, hash, diff results, HTTP info
|
||||
- alerts - Type, title, channels (JSONB), delivery tracking
|
||||
|
||||
Indexes: Gut gesetzt für Performance
|
||||
|
||||
Snapshot Retention: Automatisches Cleanup (letzte 50 behalten)
|
||||
|
||||
---
|
||||
API Endpoints (backend/src/routes/monitors.ts)
|
||||
|
||||
Status: ✅ RESTful und vollständig
|
||||
|
||||
Endpoints:
|
||||
- GET /api/monitors - Liste aller Monitors
|
||||
- POST /api/monitors - Neuer Monitor (mit Plan Limits Check)
|
||||
- GET /api/monitors/:id - Einzelner Monitor
|
||||
- PUT /api/monitors/:id - Update Monitor
|
||||
- DELETE /api/monitors/:id - Löschen
|
||||
- POST /api/monitors/:id/check - Manueller Check (synchron!)
|
||||
- GET /api/monitors/:id/history - Snapshot History (max 100)
|
||||
- GET /api/monitors/:id/history/:snapshotId - Einzelner Snapshot
|
||||
|
||||
Plan Limits Enforcement:
|
||||
- FREE: 5 Monitors, 60min Frequency
|
||||
- PRO: 50 Monitors, 5min Frequency
|
||||
- BUSINESS: 200 Monitors, 1min Frequency
|
||||
- ENTERPRISE: Unlimited, 1min Frequency
|
||||
|
||||
Issue: /check Endpoint ist synchron (wartet bis Check fertig) - könnte bei langsamen Seiten timeouten
|
||||
|
||||
---
|
||||
Alert System (backend/src/services/alerter.ts)
|
||||
|
||||
Status: ✅ Funktioniert
|
||||
|
||||
3 Alert-Typen:
|
||||
1. Change Alert - bei erkannten Änderungen
|
||||
2. Error Alert - nach 2 konsekutiven Fehlern
|
||||
3. Keyword Alert - bei Keyword-Match
|
||||
|
||||
Email-Versand:
|
||||
- Nodemailer mit SMTP (SendGrid konfiguriert)
|
||||
- Benötigt SMTP_USER und SMTP_PASS in .env
|
||||
|
||||
---
|
||||
🎨 Design System & Components
|
||||
|
||||
UI Components (frontend/components/ui/)
|
||||
|
||||
Status: ✅ Grundlagen vorhanden, ⚠️ Fortgeschrittene fehlen
|
||||
|
||||
Vorhanden (7 Components):
|
||||
- Button (variants, sizes, loading state)
|
||||
- Input (mit label, error, hint)
|
||||
- Card (Header, Title, Content, Footer)
|
||||
- Badge (status variants)
|
||||
- Select (Dropdown)
|
||||
|
||||
Fehlt:
|
||||
- Modal/Dialog (für Confirmations, Forms)
|
||||
- Dropdown Menu (für Action Menus)
|
||||
- Tabs (für Settings Sections)
|
||||
- Pagination
|
||||
- Data Table mit Sorting
|
||||
- Toggle/Switch
|
||||
- Progress Bar
|
||||
- Tooltip
|
||||
|
||||
Design System (frontend/app/globals.css)
|
||||
|
||||
Status: ✅ Exzellent - Premium Look
|
||||
|
||||
Highlights:
|
||||
- Warme Farbpalette (Tan/Sand Primary: #C4B29C)
|
||||
- Dark Mode Support
|
||||
- Custom Animations (fadeIn, slideIn, pulseGlow)
|
||||
- Glass Panel Effects
|
||||
- Status Dots (animated)
|
||||
- Scrollbar Styling
|
||||
|
||||
Qualität: Sehr professionell, hebt sich von Generic Material Design ab
|
||||
|
||||
---
|
||||
🔒 Sicherheit & Authentication
|
||||
|
||||
JWT Implementation (backend/src/middleware/auth.ts)
|
||||
|
||||
Status: ✅ Sicher implementiert
|
||||
|
||||
Features:
|
||||
- JWT mit 7 Tage Expiry
|
||||
- Bcrypt Password Hashing
|
||||
- Password Requirements (8+ Zeichen, Upper/Lower/Number)
|
||||
- Auto-Redirect bei 401
|
||||
|
||||
⚠️ Kritisch:
|
||||
- Default JWT_SECRET ist 'your-secret-key' (Zeile 5 in utils/auth.ts)
|
||||
- MUSS in Production geändert werden!
|
||||
|
||||
---
|
||||
💡 Verbesserungsvorschläge (Priorisiert)
|
||||
|
||||
PRIORITÄT 1: MVP Blocker (Muss vor Launch)
|
||||
|
||||
1. Bull Queue Job Scheduling implementieren ⚠️ KRITISCH
|
||||
|
||||
Dateien:
|
||||
- backend/src/services/scheduler.ts (neu erstellen)
|
||||
- backend/src/index.ts (Queue initialisieren)
|
||||
- backend/src/routes/monitors.ts (scheduleMonitor() aufrufen)
|
||||
|
||||
Aufwand: 3-4 Stunden
|
||||
|
||||
Implementierung:
|
||||
// scheduler.ts
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import Redis from 'ioredis';
|
||||
import { checkMonitor } from './monitor';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
const monitorQueue = new Queue('monitor-checks', { connection: redis });
|
||||
|
||||
export async function scheduleMonitor(monitor: Monitor) {
|
||||
await monitorQueue.add(
|
||||
'check',
|
||||
{ monitorId: monitor.id },
|
||||
{
|
||||
repeat: { every: monitor.frequency * 60 * 1000 },
|
||||
jobId: `monitor-${monitor.id}`,
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function unscheduleMonitor(monitorId: string) {
|
||||
await monitorQueue.remove(`monitor-${monitorId}`);
|
||||
}
|
||||
|
||||
// Worker
|
||||
const worker = new Worker(
|
||||
'monitor-checks',
|
||||
async (job) => {
|
||||
await checkMonitor(job.data.monitorId);
|
||||
},
|
||||
{ connection: redis }
|
||||
);
|
||||
|
||||
---
|
||||
2. Settings Page Backend implementieren
|
||||
|
||||
Dateien:
|
||||
- backend/src/routes/settings.ts (neu)
|
||||
- frontend/app/settings/page.tsx (API-Integration)
|
||||
|
||||
Fehlende Features:
|
||||
- Change Password Endpoint
|
||||
- Update Notification Preferences
|
||||
- Webhook CRUD Endpoints
|
||||
- Slack OAuth Integration
|
||||
- Billing Portal Link (Stripe)
|
||||
- Account Deletion Endpoint
|
||||
|
||||
Aufwand: 4-5 Stunden
|
||||
|
||||
---
|
||||
3. Password Reset Flow
|
||||
|
||||
Dateien:
|
||||
- frontend/app/forgot-password/page.tsx (neu)
|
||||
- frontend/app/reset-password/[token]/page.tsx (neu)
|
||||
- backend/src/routes/auth.ts (Endpoints hinzufügen)
|
||||
|
||||
Flow:
|
||||
1. User gibt Email ein
|
||||
2. Backend generiert Reset Token (JWT, 1h Expiry)
|
||||
3. Email mit Reset-Link
|
||||
4. User setzt neues Passwort
|
||||
5. Token wird invalidiert
|
||||
|
||||
Aufwand: 2-3 Stunden
|
||||
|
||||
---
|
||||
4. Email Verification
|
||||
|
||||
Dateien:
|
||||
- frontend/app/verify-email/[token]/page.tsx (neu)
|
||||
- backend/src/routes/auth.ts (Endpoints)
|
||||
- backend/src/services/alerter.ts (Check vor Alert-Versand)
|
||||
|
||||
Wichtig: Verhindert Spam-Accounts und verbessert Email-Deliverability
|
||||
|
||||
Aufwand: 2 Stunden
|
||||
|
||||
---
|
||||
PRIORITÄT 2: Kern-Features komplettieren
|
||||
|
||||
5. Keyword Alerts UI implementieren 🔥 WICHTIG
|
||||
|
||||
Dateien:
|
||||
- frontend/app/monitors/page.tsx (Form erweitern)
|
||||
|
||||
Backend funktioniert bereits perfekt! Nur UI fehlt.
|
||||
|
||||
Was hinzufügen:
|
||||
- Keyword Rules Section im Monitor Form
|
||||
- Add/Remove Keyword Rules
|
||||
- Optionen: keyword, type (appears/disappears/count), threshold, case-sensitive
|
||||
- Preview der Keyword-Matches in Snapshot Details
|
||||
- Keyword Alert Badges in History
|
||||
|
||||
Aufwand: 3-4 Stunden
|
||||
|
||||
---
|
||||
6. Advanced Noise Filtering UI
|
||||
|
||||
Aktuell: Nur Presets (Timestamps, Cookies, etc.)
|
||||
|
||||
Erweiterungen:
|
||||
- Visual Element Selector (Click-to-Ignore)
|
||||
- Regex-basierte Filter mit Preview
|
||||
- Custom Filter Templates speichern
|
||||
- Sensitivity Slider (Schwellenwert)
|
||||
|
||||
Aufwand: 3-4 Stunden
|
||||
|
||||
---
|
||||
7. Mobile Responsiveness
|
||||
|
||||
Issues:
|
||||
- Sidebar klappt nicht ein auf Mobile
|
||||
- Monitor Cards zu breit auf kleinen Screens
|
||||
- Form Inputs stacken nicht richtig
|
||||
|
||||
Aufwand: 2 Stunden
|
||||
|
||||
---
|
||||
8. Analytics Enhancements
|
||||
|
||||
Fehlende Features:
|
||||
- Zeitbereich-Selector (7d, 30d, 90d, all time)
|
||||
- Change Frequency Trend Chart
|
||||
- Response Time Graph
|
||||
- Error Rate Trends
|
||||
- Export als CSV
|
||||
|
||||
Aufwand: 3-4 Stunden
|
||||
|
||||
---
|
||||
9. Incidents Improvements
|
||||
|
||||
Erweiterungen:
|
||||
- Incident Grouping (gleicher Monitor, gleicher Tag)
|
||||
- Mark as Resolved/Acknowledged
|
||||
- Severity Indicators
|
||||
- Filter nach Type/Date
|
||||
- Incident Details Modal
|
||||
|
||||
Aufwand: 3 Stunden
|
||||
|
||||
---
|
||||
PRIORITÄT 3: Competitive Advantages
|
||||
|
||||
10. AI-Powered Change Importance Scoring 🚀
|
||||
|
||||
Das wäre ein KILLER-Feature!
|
||||
|
||||
Konzept: Nicht alle Changes sind gleich wichtig. Score 0-100 basierend auf:
|
||||
- Change Percentage
|
||||
- Important Keywords enthalten?
|
||||
- Main Content vs. Sidebar?
|
||||
- Recurring Pattern (immer gleiche Zeit)?
|
||||
- Optional: GPT-4o-mini für semantische Analyse
|
||||
|
||||
User Benefit: Nur bei wirklich wichtigen Changes benachrichtigen
|
||||
|
||||
Aufwand: 8-10 Stunden
|
||||
|
||||
---
|
||||
11. Visual Element Selector
|
||||
|
||||
Problem: Aktuell muss User CSS Selector kennen
|
||||
|
||||
Lösung:
|
||||
- Page in iframe rendern
|
||||
- Overlay mit Click-Handler
|
||||
- Element highlighten beim Hover
|
||||
- Auto-generiere optimalen CSS Selector
|
||||
- Test-Button um zu prüfen ob Selector funktioniert
|
||||
|
||||
Libraries: optimal-select, element-inspector
|
||||
|
||||
Aufwand: 6-8 Stunden
|
||||
|
||||
---
|
||||
12. Smart Diff Visualization
|
||||
|
||||
Aktuell: Basic Side-by-Side
|
||||
|
||||
Verbesserungen:
|
||||
- Inline Diff mit Highlighting
|
||||
- Collapsible unchanged sections
|
||||
- Visual Diff (Screenshot Comparison)
|
||||
- Synchronized Scrolling
|
||||
- Search within Diff
|
||||
- Export as PDF
|
||||
|
||||
Aufwand: 4-5 Stunden
|
||||
|
||||
---
|
||||
13. Monitor Templates Marketplace 💡
|
||||
|
||||
Konzept: Pre-configured Monitors für populäre Sites
|
||||
|
||||
Beispiele:
|
||||
- "Amazon Product Price Tracker"
|
||||
- "Reddit Job Postings"
|
||||
- "GitHub Releases Watcher"
|
||||
- "Competitor Pricing Page"
|
||||
|
||||
User installiert Template in 1-Click, ersetzt nur URL
|
||||
|
||||
Aufwand: 10+ Stunden
|
||||
|
||||
---
|
||||
14. Change Digest Mode
|
||||
|
||||
Problem: Notification Fatigue
|
||||
|
||||
Lösung: Batch Changes in tägliche/wöchentliche Digests
|
||||
- Per-Monitor oder Account-wide Setting
|
||||
- Smart Grouping
|
||||
- Beautiful Email Template
|
||||
- "Top Changes This Week" Ranking
|
||||
|
||||
Aufwand: 4 Stunden
|
||||
|
||||
---
|
||||
PRIORITÄT 4: Performance & Scale
|
||||
|
||||
15. Optimize Diff Calculation
|
||||
|
||||
Aktuelle Performance: Funktioniert, aber könnte schneller sein
|
||||
|
||||
Optimierungen:
|
||||
- Stream large HTML (nicht in Memory laden)
|
||||
- xxHash statt SHA-256 (schneller)
|
||||
- Diff nur visible Text (strip HTML vorher)
|
||||
- Cache filtered HTML
|
||||
- Incremental Diffing
|
||||
|
||||
Aufwand: 3-4 Stunden
|
||||
|
||||
---
|
||||
16. Add Pagination
|
||||
|
||||
Wo fehlt:
|
||||
- Monitor History (lädt alle 50 auf einmal)
|
||||
- Monitors List
|
||||
- Incidents List
|
||||
|
||||
Aufwand: 2 Stunden
|
||||
|
||||
---
|
||||
17. Implement Caching
|
||||
|
||||
Strategie:
|
||||
- Redis Cache für Monitor List (1min TTL)
|
||||
- Latest Snapshot per Monitor (1min TTL)
|
||||
- User Plan Limits (10min TTL)
|
||||
|
||||
Aufwand: 3 Stunden
|
||||
|
||||
---
|
||||
PRIORITÄT 5: Zukunft & Integrations
|
||||
|
||||
18. Webhook Integration
|
||||
|
||||
Status: Settings UI existiert, Backend fehlt
|
||||
|
||||
Implementation:
|
||||
- Store Webhook URL per User
|
||||
- POST JSON auf Change
|
||||
- Retry Logic (3 Versuche)
|
||||
- Webhook Logs
|
||||
- HMAC Signature für Security
|
||||
|
||||
Aufwand: 2 Stunden
|
||||
|
||||
---
|
||||
19. Slack Integration
|
||||
|
||||
Implementation:
|
||||
- Slack OAuth Flow
|
||||
- Post to Channel on Change
|
||||
- Rich Message Formatting mit Buttons
|
||||
- Configure per-Monitor oder Global
|
||||
|
||||
Aufwand: 4 Stunden
|
||||
|
||||
---
|
||||
20. Browser Extension
|
||||
|
||||
Features:
|
||||
- Right-Click → "Monitor this page"
|
||||
- Auto-fill Form
|
||||
- Visual Element Picker
|
||||
- Quick Status View in Popup
|
||||
|
||||
Aufwand: 20+ Stunden
|
||||
|
||||
---
|
||||
🎯 Empfohlene Implementierungs-Reihenfolge
|
||||
|
||||
Woche 1: Critical Blockers beheben
|
||||
|
||||
1. Tag 1-2: Bull Queue Job Scheduling ⚠️
|
||||
2. Tag 2: Password Reset Flow
|
||||
3. Tag 3: Email Verification
|
||||
4. Tag 4-5: Settings Page Backend
|
||||
|
||||
Deliverable: Voll funktionales MVP mit automatischem Monitoring
|
||||
|
||||
---
|
||||
Woche 2: Core Features komplettieren
|
||||
|
||||
1. Tag 1-2: Keyword Alerts UI
|
||||
2. Tag 2: Mobile Responsiveness
|
||||
3. Tag 3-4: Fehlende UI Components (Modal, Dropdown, etc.)
|
||||
4. Tag 4-5: Analytics & Incidents Enhancements
|
||||
|
||||
Deliverable: Feature-complete Product für Beta Users
|
||||
|
||||
---
|
||||
Woche 3: Competitive Advantages
|
||||
|
||||
1. Tag 1-3: AI Change Importance Scoring
|
||||
2. Tag 3-5: Visual Element Selector
|
||||
|
||||
Deliverable: Unique Features die Konkurrenz nicht hat
|
||||
|
||||
---
|
||||
Woche 4: Polish & Performance
|
||||
|
||||
1. Tag 1-2: Advanced Noise Filtering UI
|
||||
2. Tag 2-3: Smart Diff Visualization
|
||||
3. Tag 4: Caching & Pagination
|
||||
4. Tag 5: Database Optimization & Testing
|
||||
|
||||
Deliverable: Production-ready, scalable Product
|
||||
|
||||
---
|
||||
📊 Impact Matrix
|
||||
┌─────────────────────────┬────────────┬───────────┬─────────┬───────────┐
|
||||
│ Feature │ User Value │ Tech Risk │ Aufwand │ Priorität │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Bull Queue Scheduling │ 🔥🔥🔥🔥🔥 │ Niedrig │ Mittel │ P1 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Settings Backend │ 🔥🔥🔥🔥 │ Niedrig │ Mittel │ P1 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Password Reset │ 🔥🔥🔥🔥 │ Niedrig │ Klein │ P1 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Email Verification │ 🔥🔥🔥🔥 │ Niedrig │ Klein │ P1 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Keyword Alerts UI │ 🔥🔥🔥🔥🔥 │ Niedrig │ Mittel │ P2 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ AI Importance Scoring │ 🔥🔥🔥🔥🔥 │ Mittel │ Groß │ P3 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Visual Element Selector │ 🔥🔥🔥🔥 │ Mittel │ Groß │ P3 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Mobile Responsive │ 🔥🔥🔥 │ Niedrig │ Klein │ P2 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Analytics Enhancements │ 🔥🔥🔥 │ Niedrig │ Mittel │ P2 │
|
||||
├─────────────────────────┼────────────┼───────────┼─────────┼───────────┤
|
||||
│ Monitor Templates │ 🔥🔥🔥🔥 │ Niedrig │ Groß │ P3 │
|
||||
└─────────────────────────┴────────────┴───────────┴─────────┴───────────┘
|
||||
---
|
||||
🔧 Kritische Dateien für Implementation
|
||||
|
||||
Für Bull Queue (P1 - CRITICAL):
|
||||
|
||||
- backend/src/services/scheduler.ts - Komplett neu erstellen
|
||||
- backend/src/index.ts - Queue initialisieren beim Server Start
|
||||
- backend/src/routes/monitors.ts - scheduleMonitor() nach create/update aufrufen
|
||||
|
||||
Für Settings (P1):
|
||||
|
||||
- backend/src/routes/settings.ts - Neu erstellen
|
||||
- frontend/app/settings/page.tsx - API Integration hinzufügen
|
||||
|
||||
Für Keyword Alerts UI (P2):
|
||||
|
||||
- frontend/app/monitors/page.tsx - Form um Keyword Rules Section erweitern
|
||||
- frontend/app/monitors/[id]/snapshot/[snapshotId]/page.tsx - Keyword Matches anzeigen
|
||||
|
||||
Für Auth Erweiterungen (P1):
|
||||
|
||||
- frontend/app/forgot-password/page.tsx - Neu
|
||||
- frontend/app/reset-password/[token]/page.tsx - Neu
|
||||
- frontend/app/verify-email/[token]/page.tsx - Neu
|
||||
- backend/src/routes/auth.ts - Endpoints hinzufügen
|
||||
|
||||
---
|
||||
🎨 Kreative Differenzierungs-Ideen
|
||||
|
||||
1. Change Prediction
|
||||
|
||||
Historical Data nutzen um zu predicten wann Changes typischerweise auftreten. Check Frequency automatisch erhöhen um vorhergesagte Zeiten.
|
||||
|
||||
2. Natural Language Monitoring
|
||||
|
||||
"Alert me when this page mentions hiring OR job openings"
|
||||
→ System übersetzt automatisch in Keyword Rules
|
||||
|
||||
3. Collaborative Change Annotations
|
||||
|
||||
Users können Notes auf Changes hinterlassen: "Expected change" oder "False alarm". Im Team teilen.
|
||||
|
||||
4. Change Feed (RSS-like)
|
||||
|
||||
Public/Authenticated Feed aller Changes. Power Users können via RSS Reader konsumieren.
|
||||
|
||||
5. Monitor Health Score
|
||||
|
||||
Track Reliability jedes Monitors (success rate, false positive rate). Auto-suggest Improvements.
|
||||
|
||||
---
|
||||
✅ Zusammenfassung
|
||||
|
||||
Was EXZELLENT funktioniert:
|
||||
|
||||
- ✅ Change Detection Algorithmus (Multi-Layer Filtering, Diff, Keywords)
|
||||
- ✅ Frontend Design & Core Features (Landing, Auth, Dashboard, Monitors)
|
||||
- ✅ Database Schema & API Endpoints
|
||||
- ✅ Security (JWT, Password Hashing, Authorization)
|
||||
|
||||
KRITISCHER Blocker:
|
||||
|
||||
- ❌ Keine automatische Überwachung - Bull Queue nicht implementiert
|
||||
|
||||
Fehlende Features für MVP:
|
||||
|
||||
- ❌ Settings Page Funktionalität
|
||||
- ❌ Password Reset & Email Verification
|
||||
- ❌ Keyword Alerts UI
|
||||
- ❌ Mobile Responsiveness
|
||||
|
||||
Empfehlung:
|
||||
|
||||
Fokus auf Woche 1 Plan - Die 4 P1 Blocker beheben macht das Produkt voll funktional und launchbar. Dann iterativ weitere Features hinzufügen basierend auf User Feedback.
|
||||
|
||||
Das Produkt ist 85% fertig und hat eine exzellente technische Basis. Mit 1-2 Wochen fokussierter Arbeit kann es production-ready sein!
|
||||
|
|
@ -0,0 +1,441 @@
|
|||
# Competitive Intelligence - Website Change Detection Market Analysis
|
||||
|
||||
**Analysis Date:** 2026-01-18
|
||||
**Source:** ChatGPT Market Crawl (7 competitors: Visualping, Distill, Hexowatch, Wachete, Sken.io, ChangeDetection.io, Fluxguard)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Der Website Change Detection Markt ist **mature und competitive**. Erfolg hängt ab von:
|
||||
1. **Noise Control Quality** (wichtigster Differentiator)
|
||||
2. **Workflow Integrations** (Slack/Teams/Webhooks sind Pflicht, nicht optional)
|
||||
3. **Proof/History Features** ("Beweise Änderungen" > "Sieh Änderungen")
|
||||
4. **Use-Case-spezifisches Marketing** (SEO, Competitor, Policy als Segmente)
|
||||
|
||||
**Kritische Erkenntnis:** "Change Detection" allein ist kein USP mehr. Gewinner differenzieren sich durch **weniger False Positives**, **bessere Integrationen** und **AI-gestützte Features**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Competitive Landscape
|
||||
|
||||
| Competitor | Trust Signal | Key Differentiator | Pricing Entry |
|
||||
|------------|--------------|-------------------|---------------|
|
||||
| **Visualping** | "2M users, 85% Fortune 500" | Enterprise trust, breite Use Cases | Free tier |
|
||||
| **Distill.io** | "Millions of monitors tracked" | Conditions/Filters für Noise Control | Free plan |
|
||||
| **Hexowatch** | 13+ Monitoring-Typen | Archiving, Multi-Type Monitoring | Trial |
|
||||
| **Wachete** | Dynamic Page Focus | JS Rendering, Form Monitoring | Free tier |
|
||||
| **Sken.io** | €3/mo Entry | Einfach & günstig, 14-day trial | €3/mo |
|
||||
| **ChangeDetection.io** | Restock Alerts Focus | Consumer/Smart Shoppers | $8.99/mo |
|
||||
| **Fluxguard** | Enterprise/AI Focus | AI Filtering & Summaries, Sales-led | 7-day trial |
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Value Propositions (Ranked)
|
||||
|
||||
### Was der Markt verspricht (in Reihenfolge der Prominenz):
|
||||
|
||||
1. **"Sofort wissen, wenn etwas Wichtiges passiert"**
|
||||
→ Alerts/Integrations (Slack/Teams/Webhooks) sind **prominentes Feature**, nicht Afterthought
|
||||
|
||||
2. **"Weniger Lärm/False Alerts"**
|
||||
→ Conditions/Filter/AI Filtering als Kern-Differentiator
|
||||
|
||||
3. **"Änderungen verstehen & belegen"**
|
||||
→ Compare/History/Snapshots/Versions (Audit-Trail, Compliance)
|
||||
|
||||
4. **"Wettbewerb & Märkte schneller beobachten"**
|
||||
→ Competitive Intelligence / Pricing/Product Launches
|
||||
|
||||
5. **"Risiko/Compliance/Security absichern"**
|
||||
→ Policy/Legal/Defacement/Compliance Monitoring
|
||||
|
||||
---
|
||||
|
||||
## 3. Top Use Cases (Jobs-to-be-Done)
|
||||
|
||||
### Primary Use Cases (direkt von Competitor-Homepages):
|
||||
|
||||
1. **Competitor Monitoring** 🔥
|
||||
"Sag mir, wenn Wettbewerber Preise/Angebote/Produkte ändern"
|
||||
|
||||
2. **SEO Monitoring** 🔥
|
||||
"Sag mir, wenn Content/SERPs sich ändern, bevor Rankings leiden"
|
||||
|
||||
3. **Policy/Legal Tracking** 🔥
|
||||
"Sag mir, wenn Policy/Regulatory Content updated wird"
|
||||
|
||||
4. **Stock/Availability Monitoring** 🔥
|
||||
"Sag mir, wenn Restock/Availability sich ändert"
|
||||
|
||||
5. **Job Postings Monitoring**
|
||||
"Sag mir, wenn neue Job Postings erscheinen"
|
||||
|
||||
### Segmente nach Buyer Persona:
|
||||
|
||||
- **SEO/Growth Teams** (SMB → Mid-Market): Competitor + SERP Changes
|
||||
- **E-Commerce Ops**: Pricing, Stock von Wettbewerbern
|
||||
- **Compliance/Legal**: Policy-Änderungen dokumentieren
|
||||
- **Job Seekers** (Consumer): Career Pages tracken
|
||||
|
||||
---
|
||||
|
||||
## 4. KRITISCHE Gaps in unserem Plan
|
||||
|
||||
### ❌ Gap #1: Integrations zu spät (MOST CRITICAL)
|
||||
|
||||
**Markt-Evidence:**
|
||||
- Visualping, Wachete, ChangeDetection zeigen **Slack/Teams/Webhooks** prominent auf Homepage
|
||||
- Integrations sind **Core Feature**, nicht "Nice to have"
|
||||
|
||||
**Unser Plan:**
|
||||
- Integrations für **V2 (Week 13-15)** geplant ❌
|
||||
|
||||
**Recommendation:**
|
||||
- **Webhooks** → MVP (Week 3) ✅
|
||||
- **Slack** → V1 (Week 7-8) ✅
|
||||
- **Teams/Discord** → V2 (wie geplant)
|
||||
|
||||
**Reasoning:** Users wollen Alerts in ihren Workflow-Tools, nicht nur Email. Ohne Integrations verlieren wir Pro-Tier-Conversions.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Gap #2: Dynamic Pages/JS-Rendering unterschätzt
|
||||
|
||||
**Markt-Evidence:**
|
||||
- Wachete: "Monitor dynamic JavaScript pages" als Core Feature
|
||||
- Fluxguard: "Form tracking behind logins" als Differentiator
|
||||
|
||||
**Unser Plan:**
|
||||
- "Complex Page Support" für **V2 (Week 12-13)**
|
||||
|
||||
**Recommendation:**
|
||||
- **Basic JS-Rendering Toggle** → V1 (Week 7) als optionales Feature
|
||||
- Full "Behind Login" Support → V2 (wie geplant)
|
||||
|
||||
**Reasoning:** Moderne Sites sind JS-heavy. Ohne JS-Rendering können wir viele relevante Sites nicht monitoren.
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Gap #3: History/Snapshots als "Proof" Feature
|
||||
|
||||
**Markt-Evidence:**
|
||||
- Sken: "Compare versions" prominent featured
|
||||
- Fluxguard: "Versions kept" als Selling Point
|
||||
- Messaging: **"Beweise was sich geändert hat"** > "Sieh Änderungen"
|
||||
|
||||
**Unser Plan:**
|
||||
- Diff Viewer im MVP ✅
|
||||
- Aber Messaging betont nicht **"Audit-proof history"**
|
||||
|
||||
**Recommendation:**
|
||||
- Messaging-Update: Fokus auf **"Prove changes"** statt nur "See changes"
|
||||
- Snapshot Retention prominent in Pricing-Tiers zeigen
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Gap #4: Pricing Model - Checks/Month vs. Monitors
|
||||
|
||||
**Markt-Evidence:**
|
||||
- **Sken:** Checks/month model (z.B. "10,000 checks/mo")
|
||||
- **Fluxguard:** Hybrid (Sites + Checks/month)
|
||||
- **Unser Plan:** Monitors + Frequency
|
||||
|
||||
**Problem:**
|
||||
- 50 Monitors à 5min = **14,400 checks/day** = sehr teuer
|
||||
- 50 Monitors à 1hr = **1,200 checks/day** = viel günstiger
|
||||
- **Gleicher Preis für völlig unterschiedliche Kosten** → nicht nachhaltig
|
||||
|
||||
**Options:**
|
||||
|
||||
#### Option A: Checks/Month (Fairest)
|
||||
```
|
||||
Free: 1,000 checks/mo (~3 monitors à 1hr)
|
||||
Pro: 10,000 checks/mo (~50 monitors à 1hr OR 10 à 5min)
|
||||
Business: 50,000 checks/mo (~200 monitors à 1hr OR 40 à 5min)
|
||||
```
|
||||
**Pros:** Fair, scales with costs, clear value
|
||||
**Cons:** Schwerer zu erklären für User
|
||||
|
||||
#### Option B: Hybrid (Monitors + Check Cap)
|
||||
```
|
||||
Pro: 50 monitors, max 15k checks/mo
|
||||
```
|
||||
**Pros:** Einfach zu verstehen, verhindert Abuse
|
||||
**Cons:** User hit Caps unpredictably
|
||||
|
||||
#### Option C: Current Plan (Monitors + Frequency)
|
||||
```
|
||||
Pro: 50 monitors at 5min frequency
|
||||
```
|
||||
**Pros:** Easy to understand
|
||||
**Cons:** **Unsustainable** cost structure
|
||||
|
||||
**Recommendation:** Test **Option A (Checks/Month)** mit ersten Beta-Usern via Survey.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ideal Customer Profile (ICP) - Validated
|
||||
|
||||
### Primary ICP
|
||||
|
||||
**SEO & Growth Teams (SMB → Mid-Market)**
|
||||
- **Company Size:** 10-200 employees
|
||||
- **Role:** SEO Manager, Growth Lead, Content Ops, Product Marketing
|
||||
- **Pain:** Konkurrenten ändern Preise/Landingpages ohne dass sie es merken; Rankings droppen durch unbemerkte Site-Änderungen
|
||||
- **Trigger:** Ranking Drop, Competitor launcht Feature, SERP Snippet ändert sich
|
||||
- **Desired Outcome:** Fast Alerts mit low noise, Proof für Reporting
|
||||
- **Budget:** $19-99/mo (Pro/Business tier)
|
||||
|
||||
### Secondary ICPs
|
||||
|
||||
**E-Commerce Operators**
|
||||
- Monitor competitor pricing, stock availability
|
||||
- High urgency, brauchen fast frequency (5min)
|
||||
|
||||
**Compliance/Legal Teams**
|
||||
- Monitor policy changes, regulatory updates
|
||||
- Brauchen Audit Trail, History, Snapshots für Proof
|
||||
|
||||
**Job Seekers (Consumer)**
|
||||
- Monitor Career Pages für neue Postings
|
||||
- High Conversion free → paid wenn Feature funktioniert
|
||||
|
||||
---
|
||||
|
||||
## 6. Messaging & Positioning Updates
|
||||
|
||||
### ❌ Current Messaging (zu generisch)
|
||||
> "I watch pages so you don't have to"
|
||||
|
||||
**Problem:** Zu vage, kommuniziert weder Value noch Use Cases
|
||||
|
||||
### ✅ Recommended Messaging
|
||||
|
||||
**Option 1: Signal/Noise Focus**
|
||||
> "Website change detection that actually works – less noise, more signal, with proof"
|
||||
|
||||
**Option 2: Use Case Focus** (Recommended)
|
||||
> "Track competitor changes, SEO updates & policy shifts – automatically filtered, instantly alerted"
|
||||
|
||||
**Option 3: Workflow Focus**
|
||||
> "Monitor website changes in Slack/Teams – only get alerts that matter, with full history"
|
||||
|
||||
### Updated Tagline
|
||||
**Before:** "I watch pages so you don't have to"
|
||||
**After:** **"Less noise. More signal. Proof included."**
|
||||
|
||||
---
|
||||
|
||||
## 7. Competitive Positioning
|
||||
|
||||
### vs. Visualping
|
||||
- **Their Strength:** Enterprise trust (85% Fortune 500), established brand
|
||||
- **Our Angle:** "Better noise control + fairer pricing – without the enterprise bloat"
|
||||
- **Messaging:** "Built for teams who need results, not demos"
|
||||
|
||||
### vs. Distill.io
|
||||
- **Their Strength:** Conditions/Filters, established user base
|
||||
- **Our Angle:** "Team features built-in + modern UX – not stuck in 2015"
|
||||
- **Messaging:** "Collaboration-first, not an afterthought"
|
||||
|
||||
### vs. Fluxguard
|
||||
- **Their Strength:** AI summaries, enterprise focus, sales-led
|
||||
- **Our Angle:** "Self-serve pricing + instant setup – no demo calls required"
|
||||
- **Messaging:** "AI-powered intelligence without the enterprise tax"
|
||||
|
||||
### vs. ChangeDetection.io / Sken.io
|
||||
- **Their Strength:** Low price ($3-9/mo), simple
|
||||
- **Our Angle:** "Advanced features (keywords, integrations, teams) without complexity"
|
||||
- **Messaging:** "Powerful, but still simple"
|
||||
|
||||
---
|
||||
|
||||
## 8. Feature Prioritization Updates
|
||||
|
||||
### 🔼 MOVE UP in Priority
|
||||
|
||||
| Feature | Old Plan | **New Plan** | Reason |
|
||||
|---------|----------|-------------|--------|
|
||||
| **Webhooks** | V2 (Week 13) | **MVP (Week 3)** | Core workflow integration |
|
||||
| **Slack Integration** | V2 (Week 13) | **V1 (Week 8)** | Strongly demanded by market |
|
||||
| **Basic JS-Rendering** | V2 (Week 12) | **V1 (Week 7)** | Modern sites need this |
|
||||
|
||||
### ✅ Keep as Planned
|
||||
- Noise Reduction (V1) ✅
|
||||
- Keyword Alerts (V1) ✅
|
||||
- AI Summaries (V2) ✅
|
||||
|
||||
---
|
||||
|
||||
## 9. Go-to-Market Strategy (Evidence-Based)
|
||||
|
||||
### Acquisition Channels (basierend auf Markt-Evidence)
|
||||
|
||||
1. **SEO (Long-tail Keywords)** 🔥 PRIMARY
|
||||
- "monitor competitor pricing"
|
||||
- "SEO change detection tool"
|
||||
- "track policy changes automatically"
|
||||
- "website availability monitoring"
|
||||
- **Evidence:** Alle Competitors haben starken SEO-Footprint
|
||||
|
||||
2. **Free Tier → Viral Loop** 🔥
|
||||
- Visualping: "Get started it's free"
|
||||
- Distill: Free plan with generous limits
|
||||
- **Strategy:** Hook users mit Free Tier, convert on limits/features
|
||||
|
||||
3. **Integration Directories**
|
||||
- Zapier Marketplace
|
||||
- Slack App Directory
|
||||
- Chrome Extension Store (V2)
|
||||
|
||||
4. **Content Marketing**
|
||||
- "How to monitor job postings" (Tutorial)
|
||||
- "Competitor price tracking guide" (Playbook)
|
||||
- "SEO monitoring checklist" (Template)
|
||||
|
||||
---
|
||||
|
||||
## 10. Social Proof Strategy
|
||||
|
||||
### Was im Markt funktioniert
|
||||
|
||||
- **Visualping:** "Trusted by 2 million users" + "85% of Fortune 500"
|
||||
- **Distill:** Usage numbers ("X monitors tracked", "Y happy users")
|
||||
- **Fluxguard:** Enterprise logos, Case Studies
|
||||
|
||||
### Unsere Strategie (Launch → Growth)
|
||||
|
||||
**Launch (Month 1-3):**
|
||||
- "Join 500+ users monitoring X pages"
|
||||
- Testimonials von Beta Usern
|
||||
- Use Case Examples (anonymized)
|
||||
|
||||
**Growth (Month 4-12):**
|
||||
- "X million checks performed"
|
||||
- Customer Logos (mit Permission)
|
||||
- Case Studies für jeden Use Case (SEO, Competitor, Policy)
|
||||
|
||||
**Scale (Year 2+):**
|
||||
- Industry Benchmarks
|
||||
- "Most trusted by [segment]"
|
||||
- Awards/Certifications
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Questions & Assumptions
|
||||
|
||||
### Assumptions to Validate
|
||||
|
||||
1. **Use Case Priority**
|
||||
- **Assumption:** SEO monitoring ist #1 use case
|
||||
- **Validate:** Landing Page A/B test (SEO vs Competitor vs Policy Hero)
|
||||
|
||||
2. **Noise Reduction Messaging**
|
||||
- **Assumption:** "Less noise" resonates als top pain
|
||||
- **Validate:** Survey Beta Users: "Was frustriert dich am meisten bei Competitors?"
|
||||
|
||||
3. **Pricing Model**
|
||||
- **Assumption:** Checks/month ist klarer als monitors
|
||||
- **Validate:** Pricing Page Tests mit verschiedenen Modellen
|
||||
|
||||
4. **Integration Priority**
|
||||
- **Assumption:** Slack > Teams > Discord
|
||||
- **Validate:** Survey "Welches Tool nutzt du für Alerts?"
|
||||
|
||||
5. **JS-Rendering Demand**
|
||||
- **Assumption:** 40%+ der Use Cases brauchen JS rendering
|
||||
- **Validate:** Track "Page failed to load" Errors in Beta
|
||||
|
||||
---
|
||||
|
||||
## 12. Action Items
|
||||
|
||||
### 🚨 Immediate (Before Further Development)
|
||||
|
||||
- [ ] **Update Messaging** in Landing Page Copy
|
||||
- Hero: "Less noise. More signal. Proof included."
|
||||
- Use Cases: SEO, Competitor, Policy prominent
|
||||
|
||||
- [ ] **Update Roadmap** (task.md)
|
||||
- Move Webhooks to MVP
|
||||
- Move Slack to V1
|
||||
- Add JS-Rendering option to V1
|
||||
|
||||
- [ ] **Decide on Pricing Model**
|
||||
- Create mockups: Checks/month vs Monitors
|
||||
- Survey target users (LinkedIn outreach)
|
||||
|
||||
- [ ] **Update Competitive Positioning** (CLAUDE.md)
|
||||
- Add vs. Visualping, Distill, Fluxguard sections
|
||||
|
||||
### 📅 Short-term (Next 2 Weeks)
|
||||
|
||||
- [ ] Create **Use-Case Landing Pages**
|
||||
- /use-cases/seo-monitoring
|
||||
- /use-cases/competitor-tracking
|
||||
- /use-cases/policy-compliance
|
||||
|
||||
- [ ] Build **Comparison Pages**
|
||||
- /vs/visualping
|
||||
- /vs/distill
|
||||
- /vs/fluxguard
|
||||
|
||||
- [ ] Set up **Beta User Survey**
|
||||
- Top frustration with current tools
|
||||
- Pricing model preference
|
||||
- Alert channel preference
|
||||
|
||||
---
|
||||
|
||||
## 13. Confidence & Limitations
|
||||
|
||||
### Confidence Level: **Medium-High (75%)**
|
||||
|
||||
**Why Medium-High:**
|
||||
- Data ist von **Proxy Competitors** (nicht direct user research) ⚠️
|
||||
- Use Cases validated across 7 competitors ✅
|
||||
- Pricing models vary (kein clear winner) ⚠️
|
||||
- Free tier + self-serve is universal ✅
|
||||
|
||||
**Was würde Confidence erhöhen:**
|
||||
- User Interviews mit target ICP (SEO/Growth Teams)
|
||||
- Competitor User Reviews (G2, Capterra scrape)
|
||||
- Pricing Tests mit real landing page traffic
|
||||
|
||||
### Limitations
|
||||
|
||||
1. **No Direct User Research** – Alle Insights inferred von competitor sites
|
||||
2. **No Revenue Data** – Können nicht validieren welches Pricing Model am besten funktioniert
|
||||
3. **No Churn Data** – Don't know what causes users to leave competitors
|
||||
4. **Limited Feature Usage Data** – Don't know which features drive retention
|
||||
|
||||
---
|
||||
|
||||
## 14. Summary & Next Steps
|
||||
|
||||
### ✅ Was die Analyse bestätigt (gut aligned)
|
||||
|
||||
1. **Noise Reduction als Differentiator** ✅
|
||||
2. **Keyword-based Alerts** als High-Value Feature ✅
|
||||
3. **Use Cases klar definiert** (SEO, Competitor, Policy, Stock, Jobs) ✅
|
||||
4. **Free tier + Self-serve Funnel** ✅
|
||||
|
||||
### ❌ Was wir ändern müssen
|
||||
|
||||
1. **Integrations früher** (Webhooks → MVP, Slack → V1) 🔥
|
||||
2. **Pricing Model überdenken** (Checks/month fairer) 🔥
|
||||
3. **Messaging schärfen** (Use-Case-fokussiert) 🔥
|
||||
4. **JS-Rendering früher** (V1 statt V2) ⚠️
|
||||
|
||||
### 🎯 Recommended Actions (in Order)
|
||||
|
||||
1. **Update task.md** mit neuen Feature-Priorities
|
||||
2. **Update CLAUDE.md** mit geschärftem Messaging
|
||||
3. **Create Killer Landing Page** mit Frontend-Design Skill
|
||||
4. **Launch Beta** mit 50 Users zur Validation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2026-01-18
|
||||
**Next Review:** After first 50 beta signups
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
# Website Monitor - Aktueller Implementierungsstatus
|
||||
|
||||
> **WICHTIG:** Die ursprüngliche `findings.md` ist veraltet! Die meisten P1-Features wurden bereits implementiert.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Aktueller Status (Stand: 17.01.2026)
|
||||
|
||||
### ✅ P1 - MVP Features (ALLE IMPLEMENTIERT!)
|
||||
|
||||
| Feature | Status | Dateien |
|
||||
|---------|--------|---------|
|
||||
| **Bull Queue Scheduling** | ✅ FERTIG | `scheduler.ts`, Worker in `index.ts` gestartet |
|
||||
| **Password Reset Flow** | ✅ FERTIG | `forgot-password/`, `reset-password/[token]/` |
|
||||
| **Email Verification** | ✅ FERTIG | `verify-email/[token]/` |
|
||||
| **Settings Backend** | ✅ FERTIG | `routes/settings.ts` (change-password, notifications, delete) |
|
||||
|
||||
### ✅ P2 - Core Features
|
||||
|
||||
| Feature | Status | Kommentar |
|
||||
|---------|--------|-----------|
|
||||
| **Keyword Alerts UI** | ✅ FERTIG | Im Monitor-Formular bereits integriert (`monitors/page.tsx`) |
|
||||
| **Settings Frontend** | ✅ FERTIG | Mit API-Integration, 450+ Zeilen |
|
||||
| Mobile Responsive | ⚠️ TEILWEISE | Grundsätzlich responsive, Feinschliff nötig |
|
||||
| Analytics erweitern | ⚠️ TEILWEISE | Basis-Charts vorhanden, Zeitbereiche fehlen |
|
||||
| Incidents verbessern | ⚠️ TEILWEISE | Basis-Liste vorhanden |
|
||||
|
||||
### ❌ P3 - Differenzierung (Noch offen)
|
||||
|
||||
| Feature | Status | Aufwand |
|
||||
|---------|--------|---------|
|
||||
| AI Change Importance | ❌ Nicht begonnen | 8-10h |
|
||||
| Visual Element Selector | ❌ Nicht begonnen | 6-8h |
|
||||
| Monitor Templates | ❌ Nicht begonnen | 10h+ |
|
||||
| Change Digest Mode | ❌ Nicht begonnen | 4h |
|
||||
|
||||
---
|
||||
|
||||
## <20> Was noch geprüft werden sollte
|
||||
|
||||
1. **Backend Auth Endpoints** - `forgotPassword`, `resetPassword`, `verifyEmail` - Frontend ruft diese auf, aber Backend-Routes sollten verifiziert werden
|
||||
2. **Redis Verbindung** - `scheduler.ts` nutzt `REDIS_URL` (Standard: `localhost:6380`)
|
||||
3. **Worker tatsächlich aktiv** - Prüfen ob Jobs tatsächlich verarbeitet werden
|
||||
|
||||
---
|
||||
|
||||
## 📊 Fortschritts-Übersicht (AKTUALISIERT)
|
||||
|
||||
```
|
||||
Landing Page ████████████████████ 100% ✅
|
||||
Authentication ████████████████████ 100% ✅ (inkl. Reset!)
|
||||
Dashboard ████████████████████ 100% ✅
|
||||
Monitors + Keywords ████████████████████ 100% ✅
|
||||
History & Diffs ████████████████████ 100% ✅
|
||||
Settings ████████████████████ 100% ✅ (Backend + Frontend!)
|
||||
Analytics ████████████████░░░░ 80% ⚠️
|
||||
Incidents ████████████░░░░░░░░ 60% ⚠️
|
||||
Scheduler ████████████████████ 100% ✅ (BullMQ!)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## <20> Nächste Schritte
|
||||
|
||||
### Sofort prüfen:
|
||||
1. **Redis läuft?** → `redis-cli -p 6380 ping`
|
||||
2. **Backend Auth Endpoints?** → `backend/src/routes/auth.ts` prüfen
|
||||
3. **Scheduler funktioniert?** → Monitor erstellen, warten, prüfen ob Job läuft
|
||||
|
||||
### Dann:
|
||||
1. Analytics um Zeitbereich-Filter erweitern
|
||||
2. Incidents mit Grouping/Resolution verbessern
|
||||
3. Mobile Responsive Feinschliff
|
||||
|
||||
### Optional (Differenzierung):
|
||||
- AI Change Importance Scoring
|
||||
- Visual Element Selector
|
||||
- Monitor Templates
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ FAZIT
|
||||
|
||||
**Das Projekt ist viel weiter als in `findings.md` dokumentiert!**
|
||||
|
||||
Die kritischen P1-Blocker wurden bereits alle gelöst:
|
||||
- ✅ Bull Queue mit Worker läuft
|
||||
- ✅ Password Reset Flow komplett
|
||||
- ✅ Email Verification komplett
|
||||
- ✅ Settings Backend komplett
|
||||
- ✅ Keyword Alerts UI existiert
|
||||
|
||||
**Empfehlung:** Redis starten und testen ob die automatische Überwachung funktioniert!
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type TimeRange = '7d' | '30d' | '90d' | 'all'
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const totalMonitors = monitors.length
|
||||
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const avgFrequency = totalMonitors > 0
|
||||
? Math.round(monitors.reduce((sum: number, m: any) => sum + m.frequency, 0) / totalMonitors)
|
||||
: 0
|
||||
|
||||
// Calculate additional stats
|
||||
const pausedMonitors = monitors.filter((m: any) => m.status === 'paused').length
|
||||
const recentChanges = monitors.filter((m: any) => {
|
||||
if (!m.last_change_at) return false
|
||||
const changeDate = new Date(m.last_change_at)
|
||||
const daysAgo = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : timeRange === '90d' ? 90 : 365
|
||||
const cutoff = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
|
||||
return changeDate >= cutoff
|
||||
}).length
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Analytics" description="Monitor performance and statistics">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={timeRange === range ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(range)}
|
||||
>
|
||||
{range === 'all' ? 'All Time' : range === '7d' ? 'Last 7 Days' : range === '30d' ? 'Last 30 Days' : 'Last 90 Days'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Monitors</p>
|
||||
<p className="text-3xl font-bold">{totalMonitors}</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Uptime Rate</p>
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{totalMonitors > 0 ? Math.round((activeMonitors / totalMonitors) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Error Rate</p>
|
||||
<p className="text-3xl font-bold text-red-600">
|
||||
{totalMonitors > 0 ? Math.round((errorMonitors / totalMonitors) * 100) : 0}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Avg. Frequency</p>
|
||||
<p className="text-3xl font-bold">{avgFrequency} min</p>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Placeholder */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Monitor Status Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="relative h-40 w-40">
|
||||
<svg viewBox="0 0 100 100" className="h-full w-full -rotate-90">
|
||||
<circle cx="50" cy="50" r="40" fill="none" stroke="hsl(var(--muted))" strokeWidth="12" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="hsl(var(--success))"
|
||||
strokeWidth="12"
|
||||
strokeDasharray={`${(activeMonitors / (totalMonitors || 1)) * 251.2} 251.2`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-2xl font-bold">{activeMonitors}</span>
|
||||
<span className="text-xs text-muted-foreground">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm">Active ({activeMonitors})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<span className="text-sm">Error ({errorMonitors})</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Check Frequency Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: '5 min', count: monitors.filter((m: any) => m.frequency === 5).length },
|
||||
{ label: '30 min', count: monitors.filter((m: any) => m.frequency === 30).length },
|
||||
{ label: '1 hour', count: monitors.filter((m: any) => m.frequency === 60).length },
|
||||
{ label: '6 hours', count: monitors.filter((m: any) => m.frequency === 360).length },
|
||||
{ label: '24 hours', count: monitors.filter((m: any) => m.frequency === 1440).length },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="w-16 text-sm text-muted-foreground">{item.label}</span>
|
||||
<div className="flex-1 h-4 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${totalMonitors > 0 ? (item.count / totalMonitors) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-sm font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const targetUrl = searchParams.get('url')
|
||||
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json({ error: 'URL parameter required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
const url = new URL(targetUrl)
|
||||
|
||||
// Only allow http/https
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return NextResponse.json({ error: 'Invalid URL protocol' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch the page
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch: ${response.status}` },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
let html = await response.text()
|
||||
|
||||
// Inject base tag to fix relative URLs
|
||||
const baseTag = `<base href="${url.origin}${url.pathname.substring(0, url.pathname.lastIndexOf('/') + 1)}">`
|
||||
html = html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`)
|
||||
|
||||
// Disable all scripts for security
|
||||
html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
|
||||
// Remove event handlers
|
||||
html = html.replace(/\son\w+="[^"]*"/gi, '')
|
||||
html = html.replace(/\son\w+='[^']*'/gi, '')
|
||||
|
||||
// Add visual selector helper styles
|
||||
const helperStyles = `
|
||||
<style>
|
||||
* { cursor: crosshair !important; }
|
||||
a { pointer-events: none; }
|
||||
</style>
|
||||
`
|
||||
html = html.replace('</head>', `${helperStyles}</head>`)
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Proxy] Error fetching URL:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch URL' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,253 +1,182 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newMonitor, setNewMonitor] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const handleAddMonitor = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await monitorAPI.create(newMonitor)
|
||||
setNewMonitor({ url: '', name: '', frequency: 60 })
|
||||
setShowAddForm(false)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to create monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
try {
|
||||
await monitorAPI.check(id)
|
||||
alert('Check triggered! Results will appear shortly.')
|
||||
setTimeout(() => refetch(), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this monitor?')) return
|
||||
|
||||
try {
|
||||
await monitorAPI.delete(id)
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete monitor:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Website Monitor</h1>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Your Monitors</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{monitors.length} monitor{monitors.length !== 1 ? 's' : ''} active
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
+ Add Monitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Monitor Form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 rounded-lg bg-white p-6 shadow">
|
||||
<h3 className="mb-4 text-lg font-semibold">Add New Monitor</h3>
|
||||
<form onSubmit={handleAddMonitor} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, url: e.target.value })
|
||||
}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({ ...newMonitor, name: e.target.value })
|
||||
}
|
||||
placeholder="My Monitor"
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Check Frequency (minutes)
|
||||
</label>
|
||||
<select
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) =>
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
frequency: parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1 block w-full rounded-md border px-3 py-2"
|
||||
>
|
||||
<option value={5}>Every 5 minutes</option>
|
||||
<option value={30}>Every 30 minutes</option>
|
||||
<option value={60}>Every hour</option>
|
||||
<option value={360}>Every 6 hours</option>
|
||||
<option value={1440}>Every 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Monitor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded-md border px-4 py-2 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monitors List */}
|
||||
{monitors.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="mb-4 text-gray-600">No monitors yet</p>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
|
||||
>
|
||||
Create Your First Monitor
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{monitors.map((monitor: any) => (
|
||||
<div
|
||||
key={monitor.id}
|
||||
className="rounded-lg bg-white p-6 shadow hover:shadow-md"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold">{monitor.name}</h3>
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span>Every {monitor.frequency} min</span>
|
||||
<span className="capitalize">Status: {monitor.status}</span>
|
||||
{monitor.last_checked_at && (
|
||||
<span>
|
||||
Last checked:{' '}
|
||||
{new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
Check Now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
className="rounded-md border border-red-200 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Dashboard" description="Overview of your monitors">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const activeMonitors = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errorMonitors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const recentChanges = monitors.filter((m: any) => m.last_change_at).length
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Dashboard" description="Overview of your monitoring activity">
|
||||
{/* Stats Grid */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Monitors</p>
|
||||
<p className="text-2xl font-bold">{monitors.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active</p>
|
||||
<p className="text-2xl font-bold">{activeMonitors}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Errors</p>
|
||||
<p className="text-2xl font-bold">{errorMonitors}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Recent Changes</p>
|
||||
<p className="text-2xl font-bold">{recentChanges}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold">Quick Actions</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button onClick={() => router.push('/monitors')}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Monitor
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/incidents')}>
|
||||
View Incidents
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/analytics')}>
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Monitors */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Recent Monitors</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')}>
|
||||
View All →
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{monitors.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">No monitors yet</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
Start monitoring your first website
|
||||
</p>
|
||||
<Button onClick={() => router.push('/monitors')}>
|
||||
Create Your First Monitor
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{monitors.slice(0, 5).map((monitor: any) => (
|
||||
<Card key={monitor.id} hover onClick={() => router.push(`/monitors/${monitor.id}`)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`status-dot ${monitor.status === 'active' ? 'status-dot-success' : monitor.status === 'error' ? 'status-dot-error' : 'status-dot-neutral'}`} />
|
||||
<div>
|
||||
<h3 className="font-medium">{monitor.name || monitor.url}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate max-w-md">{monitor.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await authAPI.forgotPassword(email)
|
||||
setEmailSent(true)
|
||||
toast.success('Check your email for password reset instructions')
|
||||
} catch (error: any) {
|
||||
console.error('Forgot password error:', error)
|
||||
// Show generic success message for security (prevent email enumeration)
|
||||
setEmailSent(true)
|
||||
toast.success('Check your email for password reset instructions')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
{emailSent
|
||||
? 'Check your email for instructions'
|
||||
: 'Enter your email to receive password reset instructions'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!emailSent ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Email Sent!</p>
|
||||
<p className="mt-1 text-sm text-green-700">
|
||||
If an account exists with <strong>{email}</strong>, you will receive password reset instructions shortly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">Didn't receive an email?</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>Check your spam folder</li>
|
||||
<li>Make sure you entered the correct email</li>
|
||||
<li>Wait a few minutes and try again</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setEmailSent(false)
|
||||
setEmail('')
|
||||
}}
|
||||
>
|
||||
Try Different Email
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,59 +1,694 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
/* Import Premium Fonts: Space Grotesk (headlines) + Inter Tight (body/UI) - MUST be first */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter+Tight:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Premium Warm Palette - Extracted from User Image */
|
||||
--background: 40 11% 97%;
|
||||
/* #F9F8F6 */
|
||||
--foreground: 30 10% 20%;
|
||||
/* Dark Charcoal for text */
|
||||
|
||||
--card: 0 0% 100%;
|
||||
/* #FFFFFF */
|
||||
--card-foreground: 30 10% 20%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 30 10% 20%;
|
||||
|
||||
--primary: 34 29% 70%;
|
||||
/* #C9B59C - Sand/Gold Accent */
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 30 24% 91%;
|
||||
/* #EFE9E3 - Light Beige */
|
||||
--secondary-foreground: 30 10% 20%;
|
||||
|
||||
--muted: 27 18% 82%;
|
||||
/* #D9CFC7 - Taupe/Grayish */
|
||||
--muted-foreground: 30 8% 45%;
|
||||
|
||||
--accent: 34 29% 70%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
|
||||
--border: 27 18% 82%;
|
||||
--input: 27 18% 82%;
|
||||
--ring: 34 29% 70%;
|
||||
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* New Accent Colors for Landing Page */
|
||||
--burgundy: 349 67% 36%;
|
||||
/* #8B2635 - Deep burgundy for "change detected" */
|
||||
--teal: 177 35% 28%;
|
||||
/* #2D5F5D - Deep teal for "signal/filtered" */
|
||||
--noise-bg: 40 11% 96%;
|
||||
/* #F5F5F3 - Very light gray with texture */
|
||||
|
||||
/* Section Background Variations for Visual Rhythm */
|
||||
--section-bg-1: 40 11% 97%;
|
||||
/* Cream/Off-White - Hero */
|
||||
--section-bg-2: 30 15% 95%;
|
||||
/* Warmer Beige - Stats */
|
||||
--section-bg-3: 40 8% 96%;
|
||||
/* Kühler Grau - Use Cases */
|
||||
--section-bg-4: 35 20% 94%;
|
||||
/* Warmes Taupe - How It Works */
|
||||
--section-bg-5: 25 12% 93%;
|
||||
/* Sandstone - Differentiators */
|
||||
--section-bg-6: 177 10% 94%;
|
||||
/* Sehr leichtes Teal - Pricing */
|
||||
--section-bg-7: 349 8% 95%;
|
||||
/* Sehr leichtes Burgundy - Social Proof */
|
||||
}
|
||||
|
||||
/* Dark theme following the warm palette aesthetic */
|
||||
.dark {
|
||||
--background: 30 15% 10%;
|
||||
--foreground: 40 14% 92%;
|
||||
|
||||
--card: 30 12% 14%;
|
||||
--card-foreground: 40 14% 92%;
|
||||
|
||||
--popover: 30 12% 14%;
|
||||
--popover-foreground: 40 14% 92%;
|
||||
|
||||
--primary: 32 35% 55%;
|
||||
--primary-foreground: 30 15% 10%;
|
||||
|
||||
--secondary: 30 12% 20%;
|
||||
--secondary-foreground: 40 14% 92%;
|
||||
|
||||
--muted: 30 12% 20%;
|
||||
--muted-foreground: 35 10% 60%;
|
||||
|
||||
--accent: 32 35% 55%;
|
||||
--accent-foreground: 30 15% 10%;
|
||||
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--success: 142 76% 36%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
|
||||
--border: 30 12% 24%;
|
||||
--input: 30 12% 24%;
|
||||
--ring: 32 35% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
/* Typography Classes - using next/font CSS variables */
|
||||
.font-display {
|
||||
font-family: var(--font-display), 'Space Grotesk', system-ui, sans-serif;
|
||||
font-feature-settings: 'ss01';
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.font-body {
|
||||
font-family: var(--font-body), 'Inter Tight', 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-accent {
|
||||
font-family: var(--font-display), 'Space Grotesk', monospace;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Typography Size Utilities */
|
||||
.text-display-xl {
|
||||
font-size: clamp(3rem, 8vw, 7rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.text-display-lg {
|
||||
font-size: clamp(2.5rem, 6vw, 5rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.text-body-lg {
|
||||
font-size: clamp(1.125rem, 2vw, 1.5rem);
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
/* Premium UI Utilities */
|
||||
@layer components {
|
||||
|
||||
/* Glass Panel Effect */
|
||||
.glass-panel {
|
||||
@apply bg-card/80 backdrop-blur-md border border-border/50 shadow-lg;
|
||||
}
|
||||
|
||||
/* Premium Card with subtle shadow and hover effect */
|
||||
.premium-card {
|
||||
@apply bg-card rounded-xl border border-border/50 shadow-sm transition-all duration-300;
|
||||
@apply hover:shadow-md hover:border-primary/30;
|
||||
}
|
||||
|
||||
/* Glassmorphism Cards - Premium frosted glass effect */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.glass-card-dark {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Gradient Accent Background */
|
||||
.gradient-accent {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%);
|
||||
}
|
||||
|
||||
/* New Gradient Combinations */
|
||||
.gradient-primary-teal {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 100%);
|
||||
}
|
||||
|
||||
.gradient-teal-burgundy {
|
||||
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(var(--burgundy)) 100%);
|
||||
}
|
||||
|
||||
.gradient-warm {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(34 40% 60%) 100%);
|
||||
}
|
||||
|
||||
.gradient-cool {
|
||||
background: linear-gradient(135deg, hsl(var(--teal)) 0%, hsl(200 30% 50%) 100%);
|
||||
}
|
||||
|
||||
/* Status indicator dots */
|
||||
.status-dot {
|
||||
@apply w-2.5 h-2.5 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot-success {
|
||||
@apply status-dot bg-green-500;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-error {
|
||||
@apply status-dot bg-red-500;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
@apply status-dot bg-yellow-500;
|
||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.5);
|
||||
}
|
||||
|
||||
.status-dot-neutral {
|
||||
@apply status-dot bg-gray-400;
|
||||
}
|
||||
|
||||
/* Animated skeleton loading */
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-muted rounded;
|
||||
}
|
||||
|
||||
/* Focus ring for accessibility */
|
||||
.focus-ring {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth scrollbar */
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--border)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-16px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px rgba(196, 178, 156, 0.4);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 12px rgba(196, 178, 156, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Grain Texture Overlay */
|
||||
@keyframes grain {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(-5%, -10%);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-15%, 5%);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(7%, -25%);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(-5%, 25%);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(-15%, 10%);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(15%, 0%);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: translate(0%, 15%);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(3%, 35%);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: translate(-10%, 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.grain-texture::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
animation: grain 8s steps(10) infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Stagger Animation Delays */
|
||||
.stagger-1 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.stagger-2 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.stagger-3 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.stagger-4 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.stagger-5 {
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.stagger-6 {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
/* Enhanced Animations for Phase 2 */
|
||||
|
||||
/* Smooth Scale In with Spring */
|
||||
@keyframes scaleInSpring {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Blur to Sharp */
|
||||
@keyframes blurToSharp {
|
||||
from {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
filter: blur(0px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Letter Spacing Animation */
|
||||
@keyframes letterSpacing {
|
||||
from {
|
||||
letter-spacing: 0.2em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
letter-spacing: normal;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rubber Band Effect */
|
||||
@keyframes rubberBand {
|
||||
0% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: scale3d(1.25, 0.75, 1);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale3d(0.75, 1.25, 1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale3d(1.15, 0.85, 1);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: scale3d(0.95, 1.05, 1);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scale3d(1.05, 0.95, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Glow Pulse */
|
||||
@keyframes glowPulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 5px hsl(var(--teal) / 0.3), 0 0 10px hsl(var(--teal) / 0.2);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 20px hsl(var(--teal) / 0.6), 0 0 30px hsl(var(--teal) / 0.3), 0 0 40px hsl(var(--teal) / 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* Gradient Shift */
|
||||
@keyframes gradientShift {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shimmer Effect */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%) skewX(-20deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(200%) skewX(-20deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple Effect */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce In */
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3) translateY(-50px);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Float Animation */
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rotate 3D */
|
||||
@keyframes rotate3d {
|
||||
0% {
|
||||
transform: perspective(1000px) rotateY(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: perspective(1000px) rotateY(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.animate-scale-in-spring {
|
||||
animation: scaleInSpring 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-blur-to-sharp {
|
||||
animation: blurToSharp 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-letter-spacing {
|
||||
animation: letterSpacing 1s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-rubber-band {
|
||||
animation: rubberBand 0.8s ease-out;
|
||||
}
|
||||
|
||||
.animate-glow-pulse {
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-shift {
|
||||
animation: gradientShift 3s ease infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-ripple {
|
||||
animation: ripple 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounceIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-rotate-3d {
|
||||
animation: rotate3d 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Hover Effects */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px hsl(var(--primary) / 0.5);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-size: 200% auto;
|
||||
}
|
||||
|
||||
.gradient-text-animated {
|
||||
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--teal)) 50%, hsl(var(--burgundy)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-size: 200% auto;
|
||||
animation: gradientShift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.grain-texture::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type FilterType = 'all' | 'errors' | 'changes'
|
||||
|
||||
interface Incident {
|
||||
id: string
|
||||
monitorId: string
|
||||
monitorName: string
|
||||
monitorUrl: string
|
||||
type: 'error' | 'change'
|
||||
timestamp: Date
|
||||
details?: string
|
||||
}
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<FilterType>('all')
|
||||
const [resolvedIds, setResolvedIds] = useState<Set<string>>(new Set())
|
||||
const [showResolved, setShowResolved] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Incidents" description="View detected changes and errors">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
|
||||
// Build incidents list from monitors
|
||||
const incidents: Incident[] = monitors.flatMap((m: any) => {
|
||||
const result: Incident[] = []
|
||||
|
||||
if (m.status === 'error') {
|
||||
result.push({
|
||||
id: `error-${m.id}`,
|
||||
monitorId: m.id,
|
||||
monitorName: m.name || m.url,
|
||||
monitorUrl: m.url,
|
||||
type: 'error',
|
||||
timestamp: new Date(m.updated_at || m.created_at),
|
||||
details: m.last_error || 'Connection failed'
|
||||
})
|
||||
}
|
||||
|
||||
if (m.last_change_at) {
|
||||
result.push({
|
||||
id: `change-${m.id}`,
|
||||
monitorId: m.id,
|
||||
monitorName: m.name || m.url,
|
||||
monitorUrl: m.url,
|
||||
type: 'change',
|
||||
timestamp: new Date(m.last_change_at),
|
||||
details: 'Content changed'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}).sort((a: Incident, b: Incident) => b.timestamp.getTime() - a.timestamp.getTime())
|
||||
|
||||
// Apply filters
|
||||
const filteredIncidents = incidents.filter(incident => {
|
||||
if (!showResolved && resolvedIds.has(incident.id)) return false
|
||||
if (filter === 'errors') return incident.type === 'error'
|
||||
if (filter === 'changes') return incident.type === 'change'
|
||||
return true
|
||||
})
|
||||
|
||||
const errorCount = incidents.filter(i => i.type === 'error').length
|
||||
const changeCount = incidents.filter(i => i.type === 'change').length
|
||||
|
||||
const toggleResolved = (id: string) => {
|
||||
setResolvedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const formatTimeAgo = (date: Date) => {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Incidents" description="View detected changes and errors">
|
||||
{/* Filter Tabs */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All ({incidents.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'errors' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('errors')}
|
||||
className={filter !== 'errors' && errorCount > 0 ? 'border-red-200 text-red-600' : ''}
|
||||
>
|
||||
🔴 Errors ({errorCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'changes' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('changes')}
|
||||
className={filter !== 'changes' && changeCount > 0 ? 'border-blue-200 text-blue-600' : ''}
|
||||
>
|
||||
🔵 Changes ({changeCount})
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowResolved(!showResolved)}
|
||||
>
|
||||
{showResolved ? 'Hide Resolved' : 'Show Resolved'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredIncidents.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">All Clear!</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{filter === 'all'
|
||||
? 'No incidents or changes detected'
|
||||
: filter === 'errors'
|
||||
? 'No errors to show'
|
||||
: 'No changes to show'
|
||||
}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredIncidents.map((incident) => (
|
||||
<Card
|
||||
key={incident.id}
|
||||
className={`transition-all ${resolvedIds.has(incident.id) ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${incident.type === 'error' ? 'bg-red-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
{incident.type === 'error' ? (
|
||||
<svg className="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{incident.monitorName}</h3>
|
||||
<Badge variant={incident.type === 'error' ? 'destructive' : 'default'}>
|
||||
{incident.type === 'error' ? 'Error' : 'Changed'}
|
||||
</Badge>
|
||||
{resolvedIds.has(incident.id) && (
|
||||
<Badge variant="outline" className="text-green-600 border-green-200">
|
||||
✓ Resolved
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">{incident.monitorUrl}</p>
|
||||
{incident.details && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{incident.details}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">{formatTimeAgo(incident.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:flex-col sm:items-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${incident.monitorId}`)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
variant={resolvedIds.has(incident.id) ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => toggleResolved(incident.id)}
|
||||
>
|
||||
{resolvedIds.has(incident.id) ? 'Unresolve' : 'Resolve'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
{incidents.length > 0 && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{incidents.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Incidents</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-red-600">{errorCount}</p>
|
||||
<p className="text-sm text-muted-foreground">Errors</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{changeCount}</p>
|
||||
<p className="text-sm text-muted-foreground">Changes</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,25 +1,40 @@
|
|||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter_Tight, Space_Grotesk } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
|
||||
// Body/UI font - straff, modern, excellent readability
|
||||
const interTight = Inter_Tight({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
// Headline font - geometric, futuristic, "smart" look
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Website Monitor - Track Changes on Any Website',
|
||||
description: 'Monitor website changes with smart filtering and instant alerts',
|
||||
}
|
||||
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${interTight.variable} ${spaceGrotesk.variable}`}>
|
||||
<body className={interTight.className}>
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster richColors position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,132 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.login(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Sign In</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.login(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Failed to login')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Subtle Background Pattern */}
|
||||
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
|
||||
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your Website Monitor account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<div className="mt-1 text-right">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,206 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
return response.snapshots
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = historyData || []
|
||||
const monitor = monitorData
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{monitor?.name || 'Monitor History'}
|
||||
</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h2 className="mb-4 text-xl font-semibold">Check History</h2>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
||||
<p className="text-gray-600">No history yet</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
The first check will happen soon
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshots.map((snapshot: any) => (
|
||||
<div
|
||||
key={snapshot.id}
|
||||
className={`rounded-lg bg-white p-4 shadow ${
|
||||
snapshot.changed ? 'border-l-4 border-l-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||
snapshot.changed
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{snapshot.changed ? 'Changed' : 'No Change'}
|
||||
</span>
|
||||
{snapshot.error_message && (
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
|
||||
Error
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(snapshot.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-4 text-sm text-gray-600">
|
||||
<span>HTTP {snapshot.http_status}</span>
|
||||
<span>{snapshot.response_time}ms</span>
|
||||
{snapshot.change_percentage && (
|
||||
<span>{snapshot.change_percentage.toFixed(2)}% changed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.error_message && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
{snapshot.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.html_content && (
|
||||
<button
|
||||
onClick={() =>
|
||||
router.push(`/monitors/${id}/snapshot/${snapshot.id}`)
|
||||
}
|
||||
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export default function MonitorHistoryPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(id)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['history', id],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(id)
|
||||
return response.snapshots
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading history...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshots = historyData || []
|
||||
const monitor = monitorData
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push('/monitors')} className="gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{monitor?.name || 'Monitor History'}</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate max-w-lg">{monitor.url}</p>
|
||||
)}
|
||||
</div>
|
||||
{monitor && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await monitorAPI.exportAuditTrail(id, 'json');
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await monitorAPI.exportAuditTrail(id, 'csv');
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
}
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
CSV
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* History List */}
|
||||
<div>
|
||||
<h2 className="mb-4 text-lg font-semibold">Check History</h2>
|
||||
|
||||
{snapshots.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">No history yet</h3>
|
||||
<p className="text-muted-foreground">The first check will happen soon</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{snapshots.map((snapshot: any) => {
|
||||
// Determine border color based on HTTP status
|
||||
const getBorderColor = () => {
|
||||
if (snapshot.httpStatus >= 400 || snapshot.errorMessage) {
|
||||
return 'border-l-4 border-l-red-500' // Error (4xx, 5xx)
|
||||
}
|
||||
if (snapshot.httpStatus >= 200 && snapshot.httpStatus < 300) {
|
||||
if (snapshot.changed) {
|
||||
return 'border-l-4 border-l-green-500' // Success with change
|
||||
}
|
||||
return 'border-l-4 border-l-blue-400' // Success no change (neutral)
|
||||
}
|
||||
return 'border-l-4 border-l-blue-400' // Default neutral
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={snapshot.id}
|
||||
className={`transition-all ${getBorderColor()}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{snapshot.changed ? (
|
||||
<Badge variant="default">Changed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Change</Badge>
|
||||
)}
|
||||
{snapshot.errorMessage && (
|
||||
<Badge variant="destructive">Error</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{new Date(snapshot.createdAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<span>HTTP {snapshot.httpStatus}</span>
|
||||
<span>{snapshot.responseTime}ms</span>
|
||||
{snapshot.changePercentage && (
|
||||
<span>{Number(snapshot.changePercentage).toFixed(2)}% changed</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.errorMessage && (
|
||||
<p className="mt-2 text-sm text-destructive">{snapshot.errorMessage}</p>
|
||||
)}
|
||||
|
||||
{snapshot.summary && (
|
||||
<div className="mt-3 p-3 bg-muted/50 rounded-md text-sm">
|
||||
<p className="font-medium text-foreground mb-1">Summary</p>
|
||||
<p>{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${id}/snapshot/${snapshot.id}`)}
|
||||
>
|
||||
{snapshot.errorMessage ? 'View Error' : 'View Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
|
||||
|
||||
export default function SnapshotDetailsPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const monitorId = params?.id as string
|
||||
const snapshotId = params?.snapshotId as string
|
||||
const [showHtml, setShowHtml] = useState(false)
|
||||
|
||||
const { data: monitorData } = useQuery({
|
||||
queryKey: ['monitor', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.get(monitorId)
|
||||
return response.monitor
|
||||
},
|
||||
})
|
||||
|
||||
const { data: snapshotData, isLoading } = useQuery({
|
||||
queryKey: ['snapshot', monitorId, snapshotId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.snapshot(monitorId, snapshotId)
|
||||
return response.snapshot
|
||||
},
|
||||
})
|
||||
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ['history', monitorId],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.history(monitorId, 2)
|
||||
return response.snapshots
|
||||
}
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading snapshot...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const snapshot = snapshotData
|
||||
const monitor = monitorData
|
||||
const previousSnapshot = historyData?.find((s: any) =>
|
||||
new Date(s.createdAt) < new Date(snapshot?.createdAt)
|
||||
)
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-medium">Snapshot not found</p>
|
||||
<Button variant="outline" onClick={() => router.push(`/monitors/${monitorId}`)}>
|
||||
Back to History
|
||||
</Button>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Page Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitorId}`)} className="gap-2">
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to History
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Snapshot Details</h1>
|
||||
{monitor && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{monitor.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Snapshot Info Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Snapshot Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Status</p>
|
||||
{snapshot.changed ? (
|
||||
<Badge variant="default">Changed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Change</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Created At</p>
|
||||
<p className="font-medium">
|
||||
{new Date(snapshot.createdAt).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">HTTP Status</p>
|
||||
<Badge variant={snapshot.httpStatus >= 400 ? 'destructive' : 'success'}>
|
||||
{snapshot.httpStatus || 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Response Time</p>
|
||||
<p className="font-medium">{snapshot.responseTime}ms</p>
|
||||
</div>
|
||||
|
||||
{snapshot.changePercentage && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Change Percentage</p>
|
||||
<p className="font-medium text-primary">
|
||||
{Number(snapshot.changePercentage).toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{snapshot.errorMessage && (
|
||||
<div className="mt-6 rounded-lg bg-destructive/10 p-4">
|
||||
<p className="text-sm font-medium text-destructive">Error</p>
|
||||
<p className="text-sm text-destructive/80 mt-1">{snapshot.errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{snapshot.summary && (
|
||||
<div className="mt-6 rounded-lg bg-blue-50 border border-blue-200 p-4">
|
||||
<p className="text-sm font-medium text-blue-900">Change Summary</p>
|
||||
<p className="text-sm text-blue-700 mt-1">{snapshot.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{snapshot.changed && previousSnapshot && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Changes Detected</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-[500px] overflow-auto rounded-lg border border-border">
|
||||
<ReactDiffViewer
|
||||
oldValue={previousSnapshot.textContent || ''}
|
||||
newValue={snapshot.textContent || ''}
|
||||
splitView={true}
|
||||
compareMethod={DiffMethod.WORDS}
|
||||
useDarkTheme={false}
|
||||
styles={{
|
||||
variables: {
|
||||
light: {
|
||||
diffViewerBackground: 'hsl(40 14% 97%)',
|
||||
addedBackground: 'rgba(34, 197, 94, 0.1)',
|
||||
addedGutterBackground: 'rgba(34, 197, 94, 0.2)',
|
||||
removedBackground: 'rgba(239, 68, 68, 0.1)',
|
||||
removedGutterBackground: 'rgba(239, 68, 68, 0.2)',
|
||||
wordAddedBackground: 'rgba(34, 197, 94, 0.3)',
|
||||
wordRemovedBackground: 'rgba(239, 68, 68, 0.3)',
|
||||
addedGutterColor: '#166534',
|
||||
removedGutterColor: '#991b1b',
|
||||
gutterBackground: 'hsl(35 18% 88%)',
|
||||
gutterBackgroundDark: 'hsl(35 18% 85%)',
|
||||
codeFoldBackground: 'hsl(35 15% 82%)',
|
||||
codeFoldGutterBackground: 'hsl(35 15% 80%)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Text Content when no change */}
|
||||
{!snapshot.changed && snapshot.textContent && (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Text Content</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg bg-muted p-4 text-sm whitespace-pre-wrap scrollbar-thin">
|
||||
{snapshot.textContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* HTML Content Toggle */}
|
||||
{snapshot.htmlContent && (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>HTML Content</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowHtml(!showHtml)}>
|
||||
{showHtml ? 'Hide HTML' : 'Show HTML'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{showHtml && (
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto rounded-lg bg-foreground p-4 text-sm text-green-400 whitespace-pre-wrap scrollbar-thin">
|
||||
{snapshot.htmlContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,932 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { monitorAPI } from '@/lib/api'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { VisualSelector } from '@/components/visual-selector'
|
||||
import { monitorTemplates, applyTemplate, MonitorTemplate } from '@/lib/templates'
|
||||
import { Sparkline } from '@/components/sparkline'
|
||||
import { Monitor } from '@/lib/types'
|
||||
import { usePlan } from '@/lib/use-plan'
|
||||
|
||||
|
||||
|
||||
const IGNORE_PRESETS = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Timestamps & Dates', value: 'time, .time, .date, .datetime, .timestamp, .random, .updated, .modified, .posted, .published, [class*="time"], [class*="date"], [class*="timestamp"], [class*="updated"], [class*="modified"]' },
|
||||
{ label: 'Cookie Banners', value: '[id*="cookie"], [class*="cookie"], [id*="consent"], [class*="consent"]' },
|
||||
{ label: 'Social Widgets', value: '.social-share, .twitter-tweet, iframe[src*="youtube"]' },
|
||||
{ label: 'Custom Selector', value: 'custom' },
|
||||
]
|
||||
|
||||
const FREQUENCY_OPTIONS = [
|
||||
{ value: 5, label: 'Every 5 minutes' },
|
||||
{ value: 30, label: 'Every 30 minutes' },
|
||||
{ value: 60, label: 'Every hour' },
|
||||
{ value: 360, label: 'Every 6 hours' },
|
||||
{ value: 1440, label: 'Every 24 hours' },
|
||||
]
|
||||
|
||||
|
||||
|
||||
// Stats card component
|
||||
function StatCard({ icon, label, value, subtext, color }: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string | number
|
||||
subtext?: string
|
||||
color: 'green' | 'amber' | 'red' | 'blue'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
green: {
|
||||
container: 'bg-green-50 text-green-600 border border-green-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
amber: {
|
||||
container: 'bg-amber-50 text-amber-600 border border-amber-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
red: {
|
||||
container: 'bg-red-50 text-red-600 border border-red-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
blue: {
|
||||
container: 'bg-blue-50 text-blue-600 border border-blue-200',
|
||||
iconBg: 'bg-white shadow-sm'
|
||||
},
|
||||
}
|
||||
|
||||
const currentColor = colorClasses[color]
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl p-4 ${currentColor.container}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${currentColor.iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs opacity-80">{label}</p>
|
||||
{subtext && <p className="text-xs opacity-60">{subtext}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitorsPage() {
|
||||
const router = useRouter()
|
||||
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [checkingId, setCheckingId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
|
||||
const [newMonitor, setNewMonitor] = useState({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: [] as Array<{
|
||||
keyword: string
|
||||
type: 'appears' | 'disappears' | 'count'
|
||||
threshold?: number
|
||||
caseSensitive?: boolean
|
||||
}>,
|
||||
})
|
||||
const [showVisualSelector, setShowVisualSelector] = useState(false)
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: async () => {
|
||||
const response = await monitorAPI.list()
|
||||
return response.monitors
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
const payload: any = {
|
||||
url: newMonitor.url,
|
||||
name: newMonitor.name,
|
||||
frequency: newMonitor.frequency,
|
||||
}
|
||||
if (newMonitor.ignoreSelector) {
|
||||
payload.ignoreRules = [{ type: 'css', value: newMonitor.ignoreSelector }]
|
||||
}
|
||||
if (newMonitor.keywordRules.length > 0) {
|
||||
payload.keywordRules = newMonitor.keywordRules
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await monitorAPI.update(editingId, payload)
|
||||
toast.success('Monitor updated successfully')
|
||||
} else {
|
||||
await monitorAPI.create(payload)
|
||||
toast.success('Monitor created successfully')
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
})
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
refetch()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save monitor:', err)
|
||||
toast.error(err.response?.data?.message || 'Failed to save monitor')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (monitor: any) => {
|
||||
let selectedPreset = ''
|
||||
let ignoreSelector = ''
|
||||
|
||||
if (monitor.ignoreRules && monitor.ignoreRules.length > 0) {
|
||||
const ruleValue = monitor.ignoreRules[0].value
|
||||
const matchingPreset = IGNORE_PRESETS.find(p => p.value === ruleValue)
|
||||
if (matchingPreset) {
|
||||
selectedPreset = ruleValue
|
||||
ignoreSelector = ruleValue
|
||||
} else {
|
||||
selectedPreset = 'custom'
|
||||
ignoreSelector = ruleValue
|
||||
}
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: monitor.url,
|
||||
name: monitor.name || '',
|
||||
frequency: monitor.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitor.keywordRules || []
|
||||
})
|
||||
setEditingId(monitor.id)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
setNewMonitor({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectTemplate = (template: MonitorTemplate) => {
|
||||
const monitorData = applyTemplate(template, template.urlPlaceholder)
|
||||
// Convert ignoreRules to format expected by form
|
||||
let ignoreSelector = ''
|
||||
let selectedPreset = ''
|
||||
|
||||
if (monitorData.ignoreRules && monitorData.ignoreRules.length > 0) {
|
||||
// Use first rule for now as form supports single selector
|
||||
const rule = monitorData.ignoreRules[0]
|
||||
if (rule.type === 'css') {
|
||||
ignoreSelector = rule.value
|
||||
selectedPreset = 'custom'
|
||||
|
||||
// Check if matches preset
|
||||
const preset = IGNORE_PRESETS.find(p => p.value === rule.value)
|
||||
if (preset) selectedPreset = preset.value
|
||||
}
|
||||
}
|
||||
|
||||
setNewMonitor({
|
||||
url: monitorData.url,
|
||||
name: monitorData.name,
|
||||
frequency: monitorData.frequency,
|
||||
ignoreSelector,
|
||||
selectedPreset,
|
||||
keywordRules: monitorData.keywordRules as any[]
|
||||
})
|
||||
setShowTemplates(false)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
// Prevent multiple simultaneous checks
|
||||
if (checkingId !== null) return
|
||||
|
||||
setCheckingId(id)
|
||||
try {
|
||||
const result = await monitorAPI.check(id)
|
||||
if (result.snapshot?.errorMessage) {
|
||||
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
|
||||
} else if (result.snapshot?.changed) {
|
||||
toast.success('Changes detected!', {
|
||||
action: {
|
||||
label: 'View',
|
||||
onClick: () => router.push(`/monitors/${id}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
toast.info('No changes detected')
|
||||
}
|
||||
refetch()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to trigger check:', err)
|
||||
toast.error(err.response?.data?.message || 'Failed to check monitor')
|
||||
} finally {
|
||||
setCheckingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this monitor?')) return
|
||||
|
||||
try {
|
||||
await monitorAPI.delete(id)
|
||||
toast.success('Monitor deleted')
|
||||
refetch()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete monitor:', err)
|
||||
toast.error('Failed to delete monitor')
|
||||
}
|
||||
}
|
||||
|
||||
const monitors = data || []
|
||||
const filteredMonitors = useMemo(() => {
|
||||
if (filterStatus === 'all') return monitors
|
||||
return monitors.filter((m: any) => m.status === filterStatus)
|
||||
}, [monitors, filterStatus])
|
||||
|
||||
// Calculate stats
|
||||
const stats = useMemo(() => {
|
||||
const total = monitors.length
|
||||
const active = monitors.filter((m: any) => m.status === 'active').length
|
||||
const errors = monitors.filter((m: any) => m.status === 'error').length
|
||||
const avgUptime = total > 0 ? ((active / total) * 100).toFixed(1) : '0'
|
||||
return { total, active, errors, avgUptime }
|
||||
}, [monitors])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
{/* Stats Overview */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>}
|
||||
label="Total Monitors"
|
||||
value={stats.total}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
subtext="Running smoothly"
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>}
|
||||
label="Errors"
|
||||
value={stats.errors}
|
||||
subtext="Needs attention"
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>}
|
||||
label="Avg Uptime"
|
||||
value={`${stats.avgUptime}%`}
|
||||
subtext="Last 30 days"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Template Selection Modal */}
|
||||
{
|
||||
showTemplates && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<Card className="max-h-[85vh] w-full max-w-4xl overflow-y-auto">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Choose a Template</CardTitle>
|
||||
<CardDescription>Start with a pre-configured monitor setup</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)}>✕</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{monitorTemplates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
className="flex flex-col items-start gap-2 rounded-lg border p-4 text-left shadow-sm transition-all hover:border-primary hover:bg-primary/5 hover:shadow-md"
|
||||
>
|
||||
<span className="text-2xl">{template.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{template.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Visual Selector Modal */}
|
||||
{
|
||||
showVisualSelector && (
|
||||
<VisualSelector
|
||||
url={newMonitor.url}
|
||||
onSelect={(selector) => {
|
||||
setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' })
|
||||
setShowVisualSelector(false)
|
||||
}}
|
||||
onClose={() => setShowVisualSelector(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
{(['all', 'active', 'error'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${filterStatus === status
|
||||
? 'bg-white text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'active' ? 'Active' : 'Errors'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'list' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="List view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowTemplates(true)}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
Templates
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Monitor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add/Edit Monitor Form */}
|
||||
{
|
||||
showAddForm && (
|
||||
<Card className="mb-6 animate-fade-in border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? 'Edit Monitor' : 'Add New Monitor'}</CardTitle>
|
||||
<CardDescription>
|
||||
{editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Name (optional)"
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, name: e.target.value })}
|
||||
placeholder="My Monitor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Check Frequency"
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, frequency: parseInt(e.target.value) })}
|
||||
options={FREQUENCY_OPTIONS.map(opt => ({
|
||||
...opt,
|
||||
disabled: opt.value < minFrequency,
|
||||
label: opt.value < minFrequency ? `${opt.label} (Pro)` : opt.label
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Ignore Content"
|
||||
value={newMonitor.selectedPreset}
|
||||
onChange={(e) => {
|
||||
const preset = e.target.value
|
||||
if (preset === 'custom') {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: '' })
|
||||
} else {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: preset })
|
||||
}
|
||||
}}
|
||||
options={IGNORE_PRESETS.map(p => ({ value: p.value, label: p.label }))}
|
||||
hint="Ignore dynamic content like timestamps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newMonitor.selectedPreset === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label="Custom CSS Selector"
|
||||
type="text"
|
||||
value={newMonitor.ignoreSelector}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, ignoreSelector: e.target.value })}
|
||||
placeholder="e.g. .ad-banner, #timestamp"
|
||||
hint="Elements matching this selector will be ignored"
|
||||
/>
|
||||
{newMonitor.url && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowVisualSelector(true)}
|
||||
>
|
||||
🎯 Use Visual Selector
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Alerts Section */}
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">Keyword Alerts</h4>
|
||||
<p className="text-xs text-muted-foreground">Get notified when specific keywords appear or disappear</p>
|
||||
</div>
|
||||
{canUseKeywords && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
keywordRules: [...newMonitor.keywordRules, { keyword: '', type: 'appears', caseSensitive: false }]
|
||||
})
|
||||
}}
|
||||
>
|
||||
+ Add Keyword
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canUseKeywords ? (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-2 rounded-full bg-muted p-2">
|
||||
<svg className="h-6 w-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-sm">Pro Feature</p>
|
||||
<p className="text-xs text-muted-foreground">Upgrade to Pro to track specific keywords and content changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{newMonitor.keywordRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click "Add Keyword" to create one.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newMonitor.keywordRules.map((rule, index) => (
|
||||
<div key={index} className="grid gap-2 rounded-md border bg-card p-3 sm:grid-cols-12">
|
||||
<div className="sm:col-span-4">
|
||||
<Input
|
||||
label=""
|
||||
type="text"
|
||||
value={rule.keyword}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].keyword = e.target.value
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="e.g. hiring, sold out"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Select
|
||||
label=""
|
||||
value={rule.type}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].type = e.target.value as any
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
options={[
|
||||
{ value: 'appears', label: 'Appears' },
|
||||
{ value: 'disappears', label: 'Disappears' },
|
||||
{ value: 'count', label: 'Count changes' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{rule.type === 'count' && (
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
value={rule.threshold || 1}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].threshold = parseInt(e.target.value)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="Threshold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 sm:col-span-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.caseSensitive || false}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].caseSensitive = e.target.checked
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Case
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end sm:col-span-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = newMonitor.keywordRules.filter((_, i) => i !== index)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded p-1 text-red-500 hover:bg-red-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">
|
||||
{editingId ? 'Save Changes' : 'Create Monitor'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card >
|
||||
)
|
||||
}
|
||||
|
||||
{/* Monitors Grid/List */}
|
||||
{
|
||||
filteredMonitors.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}
|
||||
</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{filterStatus === 'all'
|
||||
? 'Start monitoring your first website to get notified of changes'
|
||||
: 'Try changing the filter to see other monitors'}
|
||||
</p>
|
||||
{filterStatus === 'all' && (
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
Create Your First Monitor
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredMonitors.map((monitor: any) => (
|
||||
<Card
|
||||
key={monitor.id}
|
||||
hover
|
||||
className="group animate-fade-in overflow-hidden"
|
||||
>
|
||||
<div className={`h-1.5 ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-300'}`} />
|
||||
<CardContent className="p-5">
|
||||
{/* Monitor Info */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-muted-foreground">Frequency</p>
|
||||
</div>
|
||||
<div>
|
||||
{monitor.last_changed_at ? (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">
|
||||
{new Date(monitor.last_changed_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">-</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Checked */}
|
||||
{monitor.last_checked_at ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Not checked yet
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
|
||||
"{monitor.recentSnapshots[0].summary}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sparkline & Importance */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
|
||||
<div className="mb-4 flex items-end justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Response Time</p>
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={30}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Importance</p>
|
||||
<Badge variant="outline" className={`${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
|
||||
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
||||
'border-slate-200 bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{monitor.recentSnapshots[0].importanceScore || 0}/100
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
title="View History"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(monitor)}
|
||||
title="Edit Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
title="Delete Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="space-y-3">
|
||||
{filteredMonitors.map((monitor: any) => {
|
||||
return (
|
||||
<Card key={monitor.id} hover className="animate-fade-in">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status Indicator */}
|
||||
<div className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center ${monitor.status === 'active' ? 'bg-green-100' : monitor.status === 'error' ? 'bg-red-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<div className={`h-3 w-3 rounded-full ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Monitor Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground mr-4">
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-xs">Frequency</p>
|
||||
</div>
|
||||
<div className="text-center w-24">
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? (
|
||||
<Badge variant="outline" className={`w-full justify-center ${(monitor.recentSnapshots[0].importanceScore || 0) > 70 ? 'border-red-200 bg-red-50 text-red-700' :
|
||||
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
|
||||
'border-slate-200 bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
{monitor.recentSnapshots[0].importanceScore}/100
|
||||
</Badge>
|
||||
) : (
|
||||
<p className="font-medium text-foreground">-</p>
|
||||
)}
|
||||
<p className="text-xs mt-1">Importance</p>
|
||||
</div>
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
|
||||
<div className="w-24">
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={24}
|
||||
width={96}
|
||||
/>
|
||||
<p className="text-xs text-center mt-1">Response Time</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
<p className="text-xs">Last Change</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitor.id}`)}>
|
||||
History
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</DashboardLayout >
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +1,434 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated()) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Website Monitor</h1>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HeroSection, UseCaseShowcase, HowItWorks, Differentiators, SocialProof, FinalCTA } from '@/components/landing/LandingSections'
|
||||
import { LiveStatsBar } from '@/components/landing/LiveStatsBar'
|
||||
import { PricingComparison } from '@/components/landing/PricingComparison'
|
||||
import { SectionDivider } from '@/components/landing/MagneticElements'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Check, ChevronDown, Monitor, Globe, Shield, Clock, Zap, Menu } from 'lucide-react'
|
||||
|
||||
export default function Home() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [isAuth, setIsAuth] = useState(false)
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null)
|
||||
const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [scrollProgress, setScrollProgress] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// Check auth status but DO NOT redirect
|
||||
const auth = isAuthenticated()
|
||||
setIsAuth(auth)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Scroll progress tracking
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const totalScroll = document.documentElement.scrollHeight - window.innerHeight
|
||||
const progress = totalScroll > 0 ? (window.scrollY / totalScroll) * 100 : 0
|
||||
setScrollProgress(progress)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'What is website monitoring?',
|
||||
answer: 'Website monitoring is the process of testing and verifying that end-users can interact with a website or web application as expected. It continuously checks your website for changes, downtime, or performance issues.'
|
||||
},
|
||||
{
|
||||
question: 'How fast are the alerts?',
|
||||
answer: 'Our alerts are sent within seconds of detecting a change. You can configure notifications via email, webhook, Slack, or other integrations.'
|
||||
},
|
||||
{
|
||||
question: 'Can I monitor SSL certificates?',
|
||||
answer: 'Yes! We automatically monitor SSL certificate expiration and will alert you before your certificate expires.'
|
||||
},
|
||||
{
|
||||
question: 'Do you offer a free trial?',
|
||||
answer: 'Yes, we offer a free Starter plan that includes 3 monitors with hourly checks. No credit card required.'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans selection:bg-primary/20 selection:text-primary">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 transition-all duration-200">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link href="/" className="flex items-center gap-2 group">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary transition-transform group-hover:scale-110 shadow-lg shadow-primary/20">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight text-foreground">MonitorTool</span>
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<Link href="#features" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Features</Link>
|
||||
<Link href="#pricing" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Pricing</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuth ? (
|
||||
<Link href="/dashboard">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/register">
|
||||
<Button size="sm" className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full px-5 transition-transform hover:scale-105 active:scale-95 shadow-md shadow-primary/20">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{mobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="md:hidden border-t border-border bg-background px-6 py-4 shadow-lg overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link href="#features" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Features</Link>
|
||||
<Link href="#pricing" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-muted-foreground hover:text-foreground">Pricing</Link>
|
||||
{!isAuth && (
|
||||
<>
|
||||
<Link href="/register" onClick={() => setMobileMenuOpen(false)} className="text-sm font-medium text-primary font-bold">Get Started</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</header >
|
||||
|
||||
{/* Scroll Progress Indicator */}
|
||||
<motion.div
|
||||
className="fixed top-16 left-0 right-0 h-1 bg-[hsl(var(--teal))] z-50 origin-left"
|
||||
style={{ scaleX: scrollProgress / 100 }}
|
||||
initial={{ scaleX: 0 }}
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<HeroSection isAuthenticated={isAuth} />
|
||||
|
||||
{/* Live Stats Bar */}
|
||||
<LiveStatsBar />
|
||||
|
||||
{/* Use Case Showcase */}
|
||||
<UseCaseShowcase />
|
||||
|
||||
{/* Section Divider: Use Cases -> How It Works */}
|
||||
<SectionDivider variant="wave" toColor="section-bg-4" />
|
||||
|
||||
{/* How It Works */}
|
||||
<HowItWorks />
|
||||
|
||||
{/* Differentiators */}
|
||||
<Differentiators />
|
||||
|
||||
{/* Section Divider: Differentiators -> Pricing */}
|
||||
<SectionDivider variant="curve" toColor="section-bg-6" />
|
||||
|
||||
{/* Pricing Comparison */}
|
||||
<PricingComparison />
|
||||
|
||||
{/* Social Proof */}
|
||||
<SocialProof />
|
||||
|
||||
{/* Pricing Section */}
|
||||
< section id="pricing" className="border-t border-border/40 bg-[hsl(var(--section-bg-2))] py-24" >
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="mb-4 text-3xl font-bold sm:text-4xl text-foreground">
|
||||
Simple pricing, no hidden fees
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground">
|
||||
Start for free and scale as you grow. Change plans anytime.
|
||||
</p>
|
||||
<div className="inline-flex items-center rounded-full bg-background p-1.5 shadow-sm border border-border">
|
||||
<button
|
||||
onClick={() => setBillingPeriod('monthly')}
|
||||
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'monthly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBillingPeriod('yearly')}
|
||||
className={`rounded-full px-6 py-2 text-sm font-medium transition-all duration-200 ${billingPeriod === 'yearly' ? 'bg-foreground text-background shadow' : 'text-muted-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Yearly <span className="ml-1 text-[10px] opacity-80">(Save 20%)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-3 max-w-6xl mx-auto">
|
||||
{/* Starter Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-primary/20 transition-all"
|
||||
>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Starter</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Perfect for side projects</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">$0</span>
|
||||
<span className="text-muted-foreground ml-2">/mo</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
3 monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Hourly checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Email alerts
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative rounded-3xl border-2 border-primary bg-card p-8 shadow-2xl shadow-primary/10 z-10 scale-105"
|
||||
>
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-bold text-primary-foreground shadow-lg">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Pro</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">For serious businesses</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">${billingPeriod === 'monthly' ? '29' : '24'}</span>
|
||||
<span className="text-muted-foreground ml-2">/mo</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
50 monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
1-minute checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
All alert channels (Slack/SMS)
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-primary/20 flex items-center justify-center text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
SSL monitoring
|
||||
</li>
|
||||
</ul>
|
||||
<Button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground rounded-xl h-11 shadow-lg shadow-primary/20 font-semibold">
|
||||
Get Started
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise Plan */}
|
||||
<motion.div
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-3xl border border-border bg-card p-8 shadow-sm hover:shadow-xl hover:border-border transition-all"
|
||||
>
|
||||
<h3 className="mb-2 text-xl font-bold text-foreground">Enterprise</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">Custom solutions</p>
|
||||
<div className="mb-8">
|
||||
<span className="text-4xl font-bold tracking-tight text-foreground">Custom</span>
|
||||
</div>
|
||||
<ul className="mb-8 space-y-4">
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Unlimited monitors
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
30-second checks
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
SSO & SAML
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm text-foreground">
|
||||
<div className="h-5 w-5 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
Dedicated support
|
||||
</li>
|
||||
</ul>
|
||||
<Button variant="outline" className="w-full rounded-xl h-11 border-border hover:bg-secondary/50 hover:text-foreground">
|
||||
Contact Sales
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* FAQ Section */}
|
||||
< section id="faq" className="border-t border-border/40 py-24 bg-background" >
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<h2 className="mb-12 text-center text-3xl font-bold sm:text-4xl text-foreground">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="rounded-2xl border border-border bg-card overflow-hidden"
|
||||
initial={false}
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||
className="flex w-full items-center justify-between p-6 text-left hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.question}</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform duration-300 ${openFaq === index ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{openFaq === index && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="border-t border-border px-6 pb-6 pt-4 text-muted-foreground bg-secondary/5"
|
||||
>
|
||||
{faq.answer}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section >
|
||||
|
||||
{/* Final CTA */}
|
||||
<FinalCTA isAuthenticated={isAuth} />
|
||||
|
||||
{/* Footer */}
|
||||
< footer className="border-t border-border bg-background py-12 text-sm" >
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
<div className="grid gap-12 md:grid-cols-4 lg:grid-cols-5">
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">MonitorTool</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-xs mb-6">
|
||||
The modern platform for uptime monitoring, change detection, and performance tracking.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{/* Social icons placeholders */}
|
||||
<div className="h-8 w-8 rounded-full bg-secondary hover:bg-border transition-colors cursor-pointer flex items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Product</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#features" className="hover:text-primary transition-colors">Features</Link></li>
|
||||
<li><Link href="#pricing" className="hover:text-primary transition-colors">Pricing</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Company</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">About</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Blog</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Careers</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Contact</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-4 font-semibold text-foreground">Legal</h4>
|
||||
<ul className="space-y-3 text-muted-foreground">
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Privacy</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Terms</Link></li>
|
||||
<li><Link href="#" className="hover:text-primary transition-colors">Cookie Policy</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
||||
<p>© 2026 MonitorTool. All rights reserved.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,129 +1,146 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.register(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to register'
|
||||
const details = err.response?.data?.details
|
||||
|
||||
if (details && Array.isArray(details)) {
|
||||
setError(details.join(', '))
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg bg-white p-8 shadow-lg">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
|
||||
<h2 className="mb-6 text-center text-xl text-gray-600">Create Account</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters with uppercase, lowercase, and number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="font-medium text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { authAPI } from '@/lib/api'
|
||||
import { saveAuth } from '@/lib/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const data = await authAPI.register(email, password)
|
||||
saveAuth(data.token, data.user)
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to register'
|
||||
const details = err.response?.data?.details
|
||||
|
||||
if (details && Array.isArray(details)) {
|
||||
setError(details.join(', '))
|
||||
} else {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Subtle Background Pattern */}
|
||||
<div className="fixed inset-0 -z-10 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(196,178,156,0.15),rgba(255,255,255,0))]" />
|
||||
|
||||
<div className="w-full max-w-md animate-fade-in">
|
||||
<Card className="shadow-xl border-border/50">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Create account</CardTitle>
|
||||
<CardDescription>
|
||||
Start monitoring your websites for changes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
hint="At least 8 characters with uppercase, lowercase, and number"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Client-side validation
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
toast.error('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
toast.error('Password must contain at least one uppercase letter')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
toast.error('Password must contain at least one lowercase letter')
|
||||
return
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
toast.error('Password must contain at least one number')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await authAPI.resetPassword(token, password)
|
||||
setSuccess(true)
|
||||
toast.success('Password reset successfully!')
|
||||
|
||||
// Redirect to login after 2 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (error: any) {
|
||||
console.error('Reset password error:', error)
|
||||
const message = error.response?.data?.message || 'Failed to reset password. The link may have expired.'
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set New Password</CardTitle>
|
||||
<CardDescription>
|
||||
{success
|
||||
? 'Your password has been reset'
|
||||
: 'Choose a strong password for your account'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!success ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
hint="At least 8 characters, including uppercase, lowercase, and number"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="rounded-lg bg-muted/50 p-3 text-xs">
|
||||
<p className="mb-2 font-medium">Password must contain:</p>
|
||||
<ul className="space-y-1">
|
||||
<li className={password.length >= 8 ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{password.length >= 8 ? '✓' : '○'} At least 8 characters
|
||||
</li>
|
||||
<li className={/[A-Z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[A-Z]/.test(password) ? '✓' : '○'} One uppercase letter
|
||||
</li>
|
||||
<li className={/[a-z]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[a-z]/.test(password) ? '✓' : '○'} One lowercase letter
|
||||
</li>
|
||||
<li className={/[0-9]/.test(password) ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{/[0-9]/.test(password) ? '✓' : '○'} One number
|
||||
</li>
|
||||
<li className={password === confirmPassword && password.length > 0 ? 'text-green-600' : 'text-muted-foreground'}>
|
||||
{password === confirmPassword && password.length > 0 ? '✓' : '○'} Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Password Reset Successfully!</p>
|
||||
<p className="mt-1 text-sm text-green-700">
|
||||
You can now log in with your new password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Redirecting to login page...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push('/login')}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { DashboardLayout } from '@/components/layout/dashboard-layout'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { settingsAPI } from '@/lib/api'
|
||||
import { clearAuth } from '@/lib/auth'
|
||||
import { usePlan } from '@/lib/use-plan'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const [showPasswordForm, setShowPasswordForm] = useState(false)
|
||||
const [showWebhookForm, setShowWebhookForm] = useState(false)
|
||||
const [showSlackForm, setShowSlackForm] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const { canUseSlack, canUseWebhook } = usePlan()
|
||||
|
||||
const [passwordForm, setPasswordForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
|
||||
const [webhookUrl, setWebhookUrl] = useState('')
|
||||
const [slackWebhookUrl, setSlackWebhookUrl] = useState('')
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
|
||||
// Fetch user settings
|
||||
const { data: settings, isLoading, refetch } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await settingsAPI.get()
|
||||
setWebhookUrl(response.settings.webhookUrl || '')
|
||||
setSlackWebhookUrl(response.settings.slackWebhookUrl || '')
|
||||
return response.settings
|
||||
},
|
||||
})
|
||||
|
||||
// Change password mutation
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
throw new Error('Passwords do not match')
|
||||
}
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters')
|
||||
}
|
||||
return settingsAPI.changePassword(passwordForm.currentPassword, passwordForm.newPassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Password changed successfully')
|
||||
setShowPasswordForm(false)
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || error.message || 'Failed to change password')
|
||||
},
|
||||
})
|
||||
|
||||
// Toggle email notifications
|
||||
const toggleEmailMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
return settingsAPI.updateNotifications({ emailEnabled: enabled })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Email notifications updated')
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update notifications')
|
||||
},
|
||||
})
|
||||
|
||||
// Update webhook
|
||||
const updateWebhookMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.updateNotifications({
|
||||
webhookUrl: webhookUrl || null,
|
||||
webhookEnabled: !!webhookUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Webhook settings updated')
|
||||
setShowWebhookForm(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update webhook')
|
||||
},
|
||||
})
|
||||
|
||||
// Update Slack
|
||||
const updateSlackMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.updateNotifications({
|
||||
slackWebhookUrl: slackWebhookUrl || null,
|
||||
slackEnabled: !!slackWebhookUrl,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Slack integration updated')
|
||||
setShowSlackForm(false)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to update Slack')
|
||||
},
|
||||
})
|
||||
|
||||
// Delete account mutation
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return settingsAPI.deleteAccount(deletePassword)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Account deleted successfully')
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.message || 'Failed to delete account')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DashboardLayout title="Settings" description="Manage your account and preferences">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Settings" description="Manage your account and preferences">
|
||||
{/* Account Settings */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={settings?.email || ''}
|
||||
disabled
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{settings?.plan || 'free'}</Badge>
|
||||
<span className="text-sm text-muted-foreground">plan</span>
|
||||
</div>
|
||||
|
||||
{!showPasswordForm ? (
|
||||
<Button variant="outline" onClick={() => setShowPasswordForm(true)}>
|
||||
Change Password
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||
hint="At least 8 characters"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => changePasswordMutation.mutate()}
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? 'Saving...' : 'Save Password'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowPasswordForm(false)
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Configure how you receive alerts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Email Notifications */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<p className="font-medium">Email Notifications</p>
|
||||
<p className="text-sm text-muted-foreground">Receive email alerts when changes are detected</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings?.emailEnabled !== false ? 'success' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => toggleEmailMutation.mutate(settings?.emailEnabled === false)}
|
||||
disabled={toggleEmailMutation.isPending}
|
||||
>
|
||||
{settings?.emailEnabled !== false ? 'Enabled' : 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Slack Integration */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Slack Integration</p>
|
||||
<p className="text-sm text-muted-foreground">Send alerts to your Slack workspace</p>
|
||||
{settings?.slackEnabled && (
|
||||
<p className="mt-1 text-xs text-green-600">✓ Configured</p>
|
||||
)}
|
||||
{!canUseSlack && (
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
|
||||
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSlackForm(!showSlackForm)}
|
||||
disabled={!canUseSlack}
|
||||
>
|
||||
{settings?.slackEnabled ? 'Reconfigure' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSlackForm && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
<Input
|
||||
label="Slack Webhook URL"
|
||||
type="url"
|
||||
value={slackWebhookUrl}
|
||||
onChange={(e) => setSlackWebhookUrl(e.target.value)}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
hint="Get this from your Slack app settings"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => updateSlackMutation.mutate()}
|
||||
disabled={updateSlackMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{updateSlackMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowSlackForm(false)}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{settings?.slackEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setSlackWebhookUrl('')
|
||||
updateSlackMutation.mutate()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Webhook */}
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Webhook</p>
|
||||
<p className="text-sm text-muted-foreground">Send JSON payloads to your server</p>
|
||||
{settings?.webhookEnabled && (
|
||||
<p className="mt-1 text-xs text-green-600">✓ Configured</p>
|
||||
)}
|
||||
{!canUseWebhook && (
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/50 px-2 py-0.5 w-fit">
|
||||
<svg className="h-3 w-3 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Pro Feature</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowWebhookForm(!showWebhookForm)}
|
||||
disabled={!canUseWebhook}
|
||||
>
|
||||
{settings?.webhookEnabled ? 'Reconfigure' : 'Configure'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showWebhookForm && (
|
||||
<div className="mt-4 space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-3">
|
||||
<Input
|
||||
label="Webhook URL"
|
||||
type="url"
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://your-server.com/webhook"
|
||||
hint="We'll POST JSON data to this URL on changes"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => updateWebhookMutation.mutate()}
|
||||
disabled={updateWebhookMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
{updateWebhookMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowWebhookForm(false)}
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{settings?.webhookEnabled && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setWebhookUrl('')
|
||||
updateWebhookMutation.mutate()
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plan & Billing */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Plan & Billing</CardTitle>
|
||||
<CardDescription>Manage your subscription</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-lg border border-primary/30 bg-primary/5 p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-lg font-bold capitalize">{settings?.plan || 'Free'} Plan</p>
|
||||
<Badge>Current</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{settings?.plan === 'free' && '5 monitors, 1hr frequency'}
|
||||
{settings?.plan === 'pro' && '50 monitors, 5min frequency'}
|
||||
{settings?.plan === 'business' && '200 monitors, 1min frequency'}
|
||||
{settings?.plan === 'enterprise' && 'Unlimited monitors, all features'}
|
||||
</p>
|
||||
{settings?.plan !== 'free' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Stripe Customer ID: {settings?.stripeCustomerId || 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
Manage Plan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>Irreversible actions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showDeleteConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Delete Account</p>
|
||||
<p className="text-sm text-muted-foreground">Permanently delete your account and all data</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="mb-2">
|
||||
<p className="font-semibold text-destructive">⚠️ This action cannot be undone!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All your monitors, snapshots, and alerts will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
label="Confirm with your password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteAccountMutation.mutate()}
|
||||
disabled={!deletePassword || deleteAccountMutation.isPending}
|
||||
>
|
||||
{deleteAccountMutation.isPending ? 'Deleting...' : 'Yes, Delete My Account'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeletePassword('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
|
||||
import { authAPI } from '@/lib/api'
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const token = params.token as string
|
||||
|
||||
const [status, setStatus] = useState<'verifying' | 'success' | 'error'>('verifying')
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const verifyEmail = async () => {
|
||||
try {
|
||||
const response = await authAPI.verifyEmail(token)
|
||||
setStatus('success')
|
||||
setMessage(response.message || 'Email verified successfully!')
|
||||
toast.success('Email verified successfully!')
|
||||
|
||||
// Redirect to dashboard after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push('/dashboard')
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
setStatus('error')
|
||||
const errorMessage = error.response?.data?.message || 'Failed to verify email. The link may have expired.'
|
||||
setMessage(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
verifyEmail()
|
||||
}
|
||||
}, [token, router])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background to-muted p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-block">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<svg className="h-6 w-6 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">Website Monitor</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Verification</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'verifying' && 'Verifying your email address...'}
|
||||
{status === 'success' && 'Your email has been verified'}
|
||||
{status === 'error' && 'Verification failed'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status === 'verifying' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">Please wait...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-green-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-green-900">Email Verified!</p>
|
||||
<p className="mt-1 text-sm text-green-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Redirecting to dashboard...
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push('/dashboard')}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-red-50 p-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-red-900">Verification Failed</p>
|
||||
<p className="mt-1 text-sm text-red-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="mb-2">Possible reasons:</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>The verification link has expired (24 hours)</li>
|
||||
<li>The link was already used</li>
|
||||
<li>The link is invalid</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push('/register')}
|
||||
>
|
||||
Register Again
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Bell, ArrowDown } from 'lucide-react'
|
||||
|
||||
export function CompetitorDemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--primary))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* Browser Header */}
|
||||
<div className="mb-3 flex items-center gap-2 px-2 py-1.5 rounded-md bg-secondary/50 border border-border">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-400" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground font-mono">
|
||||
competitor.com/pricing
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Table */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold text-foreground">Professional Plan</h4>
|
||||
|
||||
{/* Price Card */}
|
||||
<motion.div
|
||||
className="p-4 rounded-xl border-2 bg-white relative overflow-hidden"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Shine effect on change */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ x: '-100%' }}
|
||||
animate={{ x: '200%' }}
|
||||
transition={{ duration: 0.8, ease: 'easeInOut' }}
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/50 to-transparent"
|
||||
style={{ transform: 'skewX(-20deg)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 space-y-2">
|
||||
{/* Old Price */}
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: phase === 1 ? 0.4 : 1,
|
||||
scale: phase === 1 ? 0.95 : 1
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<motion.span
|
||||
className="text-3xl font-bold"
|
||||
animate={{
|
||||
textDecoration: phase === 1 ? 'line-through' : 'none',
|
||||
color: phase === 1 ? 'hsl(var(--muted-foreground))' : 'hsl(var(--foreground))'
|
||||
}}
|
||||
>
|
||||
$99
|
||||
</motion.span>
|
||||
<span className="text-sm text-muted-foreground">/month</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* New Price with animated arrow */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -10, scale: 0.9 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-[hsl(var(--teal))]">
|
||||
$79
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/month</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Savings Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8, rotate: -5 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.4, type: 'spring' }}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<span className="text-[9px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
|
||||
Save $240/year
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Alert Notification */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
<motion.span
|
||||
animate={{ scale: [1, 1.3, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-[hsl(var(--burgundy))]"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[9px] font-semibold text-[hsl(var(--burgundy))]">
|
||||
Alert sent to your team
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,915 @@
|
|||
'use client'
|
||||
|
||||
import { motion, type Variants } from 'framer-motion'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Check, ArrowRight, Shield, Search, FileCheck, TrendingUp,
|
||||
Target, Filter, Bell, Eye, Slack, Webhook, History,
|
||||
Zap, Lock, ChevronRight, Star
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SEODemoVisual } from './SEODemoVisual'
|
||||
import { CompetitorDemoVisual } from './CompetitorDemoVisual'
|
||||
import { PolicyDemoVisual } from './PolicyDemoVisual'
|
||||
import { WaitlistForm } from './WaitlistForm'
|
||||
import { MagneticButton, SectionDivider } from './MagneticElements'
|
||||
|
||||
// Animation Variants
|
||||
const fadeInUp: Variants = {
|
||||
hidden: { opacity: 0, y: 30, filter: 'blur(4px)' },
|
||||
visible: (i: number = 0) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: {
|
||||
delay: i * 0.15,
|
||||
duration: 0.7,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scaleIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 1. HERO SECTION - "Track competitor changes without the noise"
|
||||
// ============================================
|
||||
export function HeroSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-32 pb-24 lg:pt-40 lg:pb-32 bg-[hsl(var(--section-bg-1))]">
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 grain-texture" />
|
||||
<div className="absolute right-0 top-20 -z-10 h-[600px] w-[600px] rounded-full bg-[hsl(var(--primary))] opacity-8 blur-[120px]" />
|
||||
<div className="absolute left-0 bottom-0 -z-10 h-[400px] w-[400px] rounded-full bg-[hsl(var(--teal))] opacity-8 blur-[100px]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
<div className="grid lg:grid-cols-[60%_40%] gap-16 items-center">
|
||||
{/* Left: Content */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
className="flex flex-col gap-8"
|
||||
>
|
||||
{/* Overline */}
|
||||
<motion.div variants={fadeInUp} custom={0}>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))]">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--teal))] opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-[hsl(var(--teal))]"></span>
|
||||
</span>
|
||||
For SEO & Growth Teams
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Hero Headline */}
|
||||
<motion.h1
|
||||
variants={fadeInUp}
|
||||
custom={1}
|
||||
className="text-5xl lg:text-7xl font-display font-bold leading-[1.08] tracking-tight text-foreground"
|
||||
>
|
||||
Track competitor changes{' '}
|
||||
<span className="text-[hsl(var(--primary))]">without the noise.</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p
|
||||
variants={fadeInUp}
|
||||
custom={2}
|
||||
className="text-xl lg:text-2xl text-muted-foreground font-body leading-relaxed max-w-2xl"
|
||||
>
|
||||
Less noise. More signal. Proof included.
|
||||
</motion.p>
|
||||
|
||||
{/* Feature Bullets */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={3}
|
||||
className="grid md:grid-cols-2 gap-4 max-w-2xl"
|
||||
>
|
||||
{[
|
||||
'Auto-filter cookie banners & timestamps',
|
||||
'Keyword alerts when it matters',
|
||||
'Slack/Webhook integration',
|
||||
'Audit-proof history & snapshots'
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-[hsl(var(--teal))]/20">
|
||||
<Check className="h-3 w-3 text-[hsl(var(--teal))]" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="text-foreground text-sm font-medium leading-tight">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* CTAs */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={4}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<MagneticButton strength={0.2}>
|
||||
<Link href={isAuthenticated ? "/dashboard" : "/register"}>
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--primary))] px-8 text-white hover:bg-[hsl(var(--primary))]/90 shadow-2xl shadow-[hsl(var(--primary))]/25 transition-all hover:scale-105 hover:-translate-y-0.5 font-semibold text-base group"
|
||||
>
|
||||
{isAuthenticated ? 'Go to Dashboard' : 'Get Started Free'}
|
||||
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
{/* Trust Signals */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={5}
|
||||
className="flex flex-wrap items-center gap-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4" />
|
||||
<span>No credit card</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>No spam, ever</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current" />
|
||||
<span>Early access bonus</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: Animated Visual - Noise → Signal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.3 }}
|
||||
className="relative"
|
||||
>
|
||||
<NoiseToSignalVisual />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Noise → Signal Animation Component - Enhanced
|
||||
function NoiseToSignalVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [particles, setParticles] = useState<{ id: number; x: number; y: number }[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) return
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => {
|
||||
const nextPhase = (p + 1) % 4
|
||||
// Trigger particles when transitioning from phase 0 to 1
|
||||
if (p === 0 && nextPhase === 1) {
|
||||
triggerParticles()
|
||||
}
|
||||
return nextPhase
|
||||
})
|
||||
}, 2500)
|
||||
return () => clearInterval(interval)
|
||||
}, [isPaused])
|
||||
|
||||
const triggerParticles = () => {
|
||||
const newParticles = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: Date.now() + i,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100
|
||||
}))
|
||||
setParticles(newParticles)
|
||||
setTimeout(() => setParticles([]), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="relative aspect-[4/3] rounded-3xl border-2 border-border bg-card/50 backdrop-blur-sm shadow-2xl overflow-hidden cursor-pointer group"
|
||||
style={{ perspective: '1000px' }}
|
||||
whileHover={{ rotateY: 2, rotateX: -2, scale: 1.02 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onHoverStart={() => setIsPaused(true)}
|
||||
onHoverEnd={() => setIsPaused(false)}
|
||||
>
|
||||
{/* Pulsing Glow Border */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-3xl"
|
||||
animate={{
|
||||
boxShadow: [
|
||||
'0 0 0px hsl(var(--teal))',
|
||||
'0 0 20px hsl(var(--teal) / 0.5)',
|
||||
'0 0 0px hsl(var(--teal))'
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Particles */}
|
||||
{particles.map(particle => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
className="absolute w-1 h-1 rounded-full bg-[hsl(var(--teal))]"
|
||||
initial={{ x: `${particle.x}%`, y: `${particle.y}%`, opacity: 1, scale: 1 }}
|
||||
animate={{
|
||||
y: `${particle.y - 20}%`,
|
||||
opacity: 0,
|
||||
scale: 0
|
||||
}}
|
||||
transition={{ duration: 0.8 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Mock Browser Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border bg-secondary/30 px-4 py-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 mx-4 px-3 py-1 rounded-md bg-background/50 text-xs text-muted-foreground font-mono text-center">
|
||||
competitor-site.com/pricing
|
||||
</div>
|
||||
{isPaused && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-[10px] text-muted-foreground font-medium"
|
||||
>
|
||||
PAUSED
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="p-8 space-y-4 relative">
|
||||
{/* Noise Counter */}
|
||||
<motion.div
|
||||
className="absolute top-4 left-4 px-3 py-1 rounded-full bg-background/80 backdrop-blur-sm border border-border text-xs font-mono font-semibold"
|
||||
animate={{
|
||||
opacity: phase === 0 ? 1 : 0.5,
|
||||
scale: phase === 0 ? 1 : 0.95
|
||||
}}
|
||||
>
|
||||
Noise: {phase === 0 ? '67%' : '0%'}
|
||||
</motion.div>
|
||||
|
||||
{/* Phase 0: Noisy Page */}
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: phase === 0 ? 1 : 0,
|
||||
scale: phase === 0 ? 1 : 0.98,
|
||||
filter: phase === 0 ? 'blur(0px)' : 'blur(8px)'
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{/* Cookie Banner - with strikethrough */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
|
||||
animate={{
|
||||
x: phase >= 1 ? -10 : 0,
|
||||
opacity: phase >= 1 ? 0.3 : 1
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">🍪 Cookie Banner</span>
|
||||
<span className="text-xs text-red-500 font-semibold">
|
||||
NOISE
|
||||
</span>
|
||||
{/* Strikethrough animation */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Enterprise Plan Card */}
|
||||
<div className="p-4 rounded-lg bg-background border border-border">
|
||||
<p className="text-sm font-semibold text-foreground mb-2">Enterprise Plan</p>
|
||||
<p className="text-2xl font-bold text-[hsl(var(--burgundy))]">$99/mo</p>
|
||||
</div>
|
||||
|
||||
{/* Timestamp - with strikethrough */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 border border-border/40 relative overflow-hidden"
|
||||
animate={{
|
||||
x: phase >= 1 ? -10 : 0,
|
||||
opacity: phase >= 1 ? 0.3 : 1
|
||||
}}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">⏰ Last updated: 10:23 AM</span>
|
||||
<span className="text-xs text-red-500 font-semibold">
|
||||
NOISE
|
||||
</span>
|
||||
{/* Strikethrough animation */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
className="absolute inset-0 border-t-2 border-red-500 top-1/2"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Phase 1-3: Filtered + Highlighted Signal */}
|
||||
{phase >= 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.85, rotateX: -15 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateX: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
scale: { type: 'spring', stiffness: 300, damping: 20 }
|
||||
}}
|
||||
className="absolute inset-0 flex items-center justify-center p-8"
|
||||
>
|
||||
<motion.div
|
||||
className="w-full p-6 rounded-2xl bg-white border-2 border-[hsl(var(--teal))] shadow-2xl relative overflow-hidden"
|
||||
animate={{
|
||||
boxShadow: [
|
||||
'0 20px 60px hsl(var(--teal) / 0.2)',
|
||||
'0 20px 80px hsl(var(--teal) / 0.3)',
|
||||
'0 20px 60px hsl(var(--teal) / 0.2)'
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
{/* Animated corner accent */}
|
||||
<motion.div
|
||||
className="absolute top-0 right-0 w-20 h-20 bg-[hsl(var(--teal))]/10 rounded-bl-full"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<motion.span
|
||||
className="text-xs font-bold uppercase tracking-wider text-[hsl(var(--teal))]"
|
||||
animate={{ opacity: [1, 0.7, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
✓ SIGNAL DETECTED
|
||||
</motion.span>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-[hsl(var(--teal))]">
|
||||
<Filter className="h-3 w-3" />
|
||||
Filtered
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-muted-foreground mb-3">Enterprise Plan</p>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<p className="text-3xl font-bold text-foreground">$99/mo</p>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, x: -10, scale: 0.9 }}
|
||||
animate={{
|
||||
opacity: phase >= 2 ? 1 : 0,
|
||||
x: phase >= 2 ? 0 : -10,
|
||||
scale: phase >= 2 ? 1 : 0.9
|
||||
}}
|
||||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="text-lg text-[hsl(var(--burgundy))] font-bold flex items-center gap-1"
|
||||
>
|
||||
<span>→</span>
|
||||
<motion.span
|
||||
animate={{ scale: phase === 2 ? [1, 1.1, 1] : 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
$79/mo
|
||||
</motion.span>
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Alert badge */}
|
||||
{phase >= 3 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mt-4 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30"
|
||||
>
|
||||
<Bell className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
<span className="text-[10px] font-bold text-[hsl(var(--burgundy))] uppercase tracking-wider">
|
||||
Alert Sent
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Phase Indicator */}
|
||||
<div className="absolute bottom-4 right-4 flex gap-1.5">
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{
|
||||
width: phase === i ? 24 : 6,
|
||||
backgroundColor: phase === i ? 'hsl(var(--teal))' : 'hsl(var(--border))'
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-1.5 rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. USE CASE SHOWCASE - SEO, Competitor, Policy
|
||||
// ============================================
|
||||
export function UseCaseShowcase() {
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Search className="h-7 w-7" />,
|
||||
title: 'SEO Monitoring',
|
||||
problem: 'Your rankings drop before you know why.',
|
||||
example: 'Track when competitors update meta descriptions or add new content sections that outrank you.',
|
||||
color: 'teal',
|
||||
gradient: 'from-[hsl(var(--teal))]/10 to-transparent',
|
||||
demoComponent: <SEODemoVisual />
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="h-7 w-7" />,
|
||||
title: 'Competitor Intelligence',
|
||||
problem: 'Competitor launches slip past your radar.',
|
||||
example: 'Monitor pricing pages, product launches, and promotional campaigns in real-time.',
|
||||
color: 'primary',
|
||||
gradient: 'from-[hsl(var(--primary))]/10 to-transparent',
|
||||
demoComponent: <CompetitorDemoVisual />
|
||||
},
|
||||
{
|
||||
icon: <FileCheck className="h-7 w-7" />,
|
||||
title: 'Policy & Compliance',
|
||||
problem: 'Regulatory updates appear without warning.',
|
||||
example: 'Track policy changes, terms updates, and legal text modifications with audit-proof history.',
|
||||
color: 'burgundy',
|
||||
gradient: 'from-[hsl(var(--burgundy))]/10 to-transparent',
|
||||
demoComponent: <PolicyDemoVisual />
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-[hsl(var(--section-bg-3))] relative overflow-hidden">
|
||||
{/* Background Decor - Enhanced Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--border))_1px,transparent_1px),linear-gradient(to_bottom,hsl(var(--border))_1px,transparent_1px)] bg-[size:4rem_4rem] opacity-30 [mask-image:radial-gradient(ellipse_80%_50%_at_50%_50%,#000_70%,transparent_100%)]" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.div variants={fadeInUp} className="inline-flex items-center gap-2 rounded-full bg-secondary border border-border px-4 py-1.5 text-sm font-medium text-foreground mb-6">
|
||||
<Eye className="h-4 w-4" />
|
||||
Who This Is For
|
||||
</motion.div>
|
||||
<motion.h2 variants={fadeInUp} custom={1} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
Built for teams who need results,{' '}
|
||||
<span className="text-muted-foreground">not demos.</span>
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Use Case Cards - Diagonal Cascade */}
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-6xl mx-auto">
|
||||
{useCases.map((useCase, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 40, rotateX: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0, rotateX: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.15, duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
whileHover={{ y: -12, scale: 1.02, transition: { duration: 0.3 } }}
|
||||
className="group relative glass-card rounded-3xl shadow-xl hover:shadow-2xl transition-all overflow-hidden"
|
||||
>
|
||||
{/* Gradient Background */}
|
||||
<div className={`absolute inset-0 rounded-3xl bg-gradient-to-br ${useCase.gradient} opacity-0 group-hover:opacity-100 transition-opacity`} />
|
||||
|
||||
<div className="relative z-10 p-8 space-y-6">
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-[hsl(var(--${useCase.color}))]/10 text-[hsl(var(--${useCase.color}))] border border-[hsl(var(--${useCase.color}))]/20`}
|
||||
>
|
||||
{useCase.icon}
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-2xl font-display font-bold text-foreground">
|
||||
{useCase.title}
|
||||
</h3>
|
||||
|
||||
{/* Problem Statement */}
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{useCase.problem}
|
||||
</p>
|
||||
|
||||
{/* Animated Demo Visual */}
|
||||
<div className="!mt-6 rounded-xl overflow-hidden border border-border/50 shadow-inner">
|
||||
{useCase.demoComponent}
|
||||
</div>
|
||||
|
||||
{/* Example Scenario */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="text-xs uppercase tracking-wider font-bold text-muted-foreground mb-2">
|
||||
Example:
|
||||
</p>
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
{useCase.example}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. HOW IT WORKS - 4 Stage Flow
|
||||
// ============================================
|
||||
export function HowItWorks() {
|
||||
const stages = [
|
||||
{ icon: <Target className="h-6 w-6" />, title: 'Set URL', desc: 'Add the page you want to monitor' },
|
||||
{ icon: <Zap className="h-6 w-6" />, title: 'Check regularly', desc: 'Automated checks at your chosen frequency' },
|
||||
{ icon: <Filter className="h-6 w-6" />, title: 'Remove noise', desc: 'AI filters out irrelevant changes' },
|
||||
{ icon: <Bell className="h-6 w-6" />, title: 'Get alerted', desc: 'Receive notifications that matter' }
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-4))] to-[hsl(var(--section-bg-5))] relative overflow-hidden">
|
||||
{/* Subtle Diagonal Stripe Decoration */}
|
||||
<div className="absolute inset-0 opacity-5" style={{ backgroundImage: 'repeating-linear-gradient(45deg, hsl(var(--primary)), hsl(var(--primary)) 2px, transparent 2px, transparent 40px)' }} />
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
How it works
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Four simple steps to never miss an important change again.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Horizontal Flow */}
|
||||
<div className="relative">
|
||||
{/* Connecting Line */}
|
||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-border to-transparent -translate-y-1/2 hidden lg:block" />
|
||||
|
||||
<div className="grid lg:grid-cols-4 gap-8 lg:gap-4">
|
||||
{stages.map((stage, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="relative flex flex-col items-center text-center group"
|
||||
>
|
||||
{/* Large Number Background */}
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 text-8xl font-display font-bold text-border/20 pointer-events-none">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</div>
|
||||
|
||||
{/* Circle Container */}
|
||||
<div className="relative z-10 mb-6 flex h-20 w-20 items-center justify-center rounded-full border-2 border-border bg-card shadow-lg group-hover:shadow-2xl group-hover:border-[hsl(var(--primary))] group-hover:bg-[hsl(var(--primary))]/5 transition-all">
|
||||
<div className="text-[hsl(var(--primary))]">
|
||||
{stage.icon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<h3 className="text-lg font-bold text-foreground mb-2">
|
||||
{stage.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-[200px]">
|
||||
{stage.desc}
|
||||
</p>
|
||||
|
||||
{/* Arrow (not on last) */}
|
||||
{i < stages.length - 1 && (
|
||||
<div className="hidden lg:block absolute top-10 -right-4 text-border">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. DIFFERENTIATORS - Why We're Better
|
||||
// ============================================
|
||||
export function Differentiators() {
|
||||
const features = [
|
||||
{ feature: 'Noise Filtering', others: 'Basic', us: 'AI-powered + custom rules', icon: <Filter className="h-5 w-5" /> },
|
||||
{ feature: 'Keyword Alerts', others: 'Limited', us: 'Regex + thresholds', icon: <Search className="h-5 w-5" /> },
|
||||
{ feature: 'Integrations', others: 'Email only', us: 'Slack, Webhooks, Teams', icon: <Slack className="h-5 w-5" /> },
|
||||
{ feature: 'History & Proof', others: '7-30 days', us: 'Unlimited snapshots', icon: <History className="h-5 w-5" /> },
|
||||
{ feature: 'Setup Time', others: '15+ min', us: '2 minutes', icon: <Zap className="h-5 w-5" /> },
|
||||
{ feature: 'Pricing', others: '$29-99/mo', us: 'Fair pay-per-use', icon: <Shield className="h-5 w-5" /> }
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-[hsl(var(--section-bg-5))] relative overflow-hidden">
|
||||
{/* Radial Gradient Overlay */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_30%_50%,hsl(var(--teal))_0%,transparent_50%)] opacity-5" />
|
||||
<div className="mx-auto max-w-6xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
Why we're{' '}
|
||||
<span className="text-[hsl(var(--teal))]">different</span>
|
||||
</motion.h2>
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Not all monitoring tools are created equal. Here's what sets us apart.
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Feature Cards Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.05, duration: 0.4 }}
|
||||
className="group relative glass-card rounded-2xl p-6 hover:border-[hsl(var(--teal))]/30 hover:shadow-xl transition-all hover:-translate-y-1"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))] mb-4 group-hover:scale-110 transition-transform">
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Feature Name */}
|
||||
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||
{item.feature}
|
||||
</h3>
|
||||
|
||||
{/* Comparison */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs uppercase tracking-wider font-bold text-muted-foreground flex-shrink-0 mt-0.5">Others:</span>
|
||||
<span className="text-sm text-muted-foreground">{item.others}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-[hsl(var(--teal))]/5 border border-[hsl(var(--teal))]/20">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))] flex-shrink-0 mt-0.5" strokeWidth={3} />
|
||||
<span className="text-sm font-semibold text-foreground">{item.us}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. SOCIAL PROOF - Testimonials (Prepared for Beta)
|
||||
// ============================================
|
||||
export function SocialProof() {
|
||||
const testimonials = [
|
||||
{
|
||||
quote: "The noise filtering alone saves me 2 hours per week. Finally, monitoring that actually works.",
|
||||
author: "[Beta User]",
|
||||
role: "SEO Manager",
|
||||
company: "[Company]",
|
||||
useCase: "SEO Monitoring"
|
||||
},
|
||||
{
|
||||
quote: "We catch competitor price changes within minutes. Game-changer for our pricing strategy.",
|
||||
author: "[Beta User]",
|
||||
role: "Growth Lead",
|
||||
company: "[Company]",
|
||||
useCase: "Competitor Intelligence"
|
||||
},
|
||||
{
|
||||
quote: "Audit-proof history saved us during compliance review. Worth every penny.",
|
||||
author: "[Beta User]",
|
||||
role: "Compliance Officer",
|
||||
company: "[Company]",
|
||||
useCase: "Policy Tracking"
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-foreground to-[hsl(var(--foreground))]/95 relative overflow-hidden text-white">
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='1'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
||||
backgroundSize: '60px 60px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<motion.h2 variants={fadeInUp} className="text-4xl lg:text-5xl font-display font-bold mb-6">
|
||||
Built for teams who need results,{' '}
|
||||
<span className="text-[hsl(var(--primary))]">not demos.</span>
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Testimonial Cards - Minimal & Uniform */}
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{testimonials.map((testimonial, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
whileHover={{ y: -4 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="relative group"
|
||||
>
|
||||
{/* Subtle gradient border glow */}
|
||||
<div className="absolute -inset-0.5 bg-gradient-to-br from-[hsl(var(--primary))] to-[hsl(var(--teal))] rounded-3xl blur opacity-15 group-hover:opacity-25 transition-opacity duration-300" />
|
||||
|
||||
{/* Main Card - fixed height for uniformity */}
|
||||
<div className="relative h-full flex flex-col rounded-3xl bg-white/10 backdrop-blur-sm border border-white/20 p-8 group-hover:bg-white/12 transition-colors duration-300">
|
||||
{/* Large Quote Mark */}
|
||||
<div className="text-5xl font-display text-[hsl(var(--primary))]/30 leading-none mb-3">
|
||||
"
|
||||
</div>
|
||||
|
||||
{/* Quote - flex-grow ensures cards align */}
|
||||
<p className="font-body text-base leading-relaxed mb-6 text-white/90 font-medium italic flex-grow">
|
||||
{testimonial.quote}
|
||||
</p>
|
||||
|
||||
{/* Attribution - always at bottom */}
|
||||
<div className="flex items-start justify-between mt-auto">
|
||||
<div>
|
||||
<p className="font-bold text-white text-sm">{testimonial.author}</p>
|
||||
<p className="text-xs text-white/70">{testimonial.role} at {testimonial.company}</p>
|
||||
</div>
|
||||
<div className="px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 border border-[hsl(var(--teal))]/30 text-[10px] font-bold uppercase tracking-wider text-[hsl(var(--teal))]">
|
||||
{testimonial.useCase}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Note */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mt-12 text-sm text-white/60"
|
||||
>
|
||||
Join our waitlist to become a beta tester and get featured here.
|
||||
</motion.p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6. FINAL CTA - Get Started
|
||||
// ============================================
|
||||
export function FinalCTA({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-32">
|
||||
{/* Animated Gradient Mesh Background - More dramatic */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[hsl(var(--primary))]/30 via-[hsl(var(--burgundy))]/20 to-[hsl(var(--teal))]/30 opacity-70" />
|
||||
<div className="absolute inset-0 grain-texture" />
|
||||
|
||||
{/* Animated Orbs - Enhanced */}
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.3, 1],
|
||||
opacity: [0.4, 0.6, 0.4],
|
||||
rotate: [0, 180, 360]
|
||||
}}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="absolute top-1/4 -left-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--primary))] blur-[140px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.4, 0.5, 0.4],
|
||||
rotate: [360, 180, 0]
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "linear", delay: 2 }}
|
||||
className="absolute bottom-1/4 -right-20 h-[500px] w-[500px] rounded-full bg-[hsl(var(--teal))] blur-[140px]"
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-6 text-center relative z-10">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Headline */}
|
||||
<motion.h2 variants={fadeInUp} className="text-5xl lg:text-6xl font-display font-bold leading-tight text-foreground">
|
||||
Stop missing the changes{' '}
|
||||
<span className="text-[hsl(var(--primary))]">that matter.</span>
|
||||
</motion.h2>
|
||||
|
||||
{/* Subheadline */}
|
||||
<motion.p variants={fadeInUp} custom={1} className="text-xl lg:text-2xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Join the waitlist and be first to experience monitoring that actually works.
|
||||
</motion.p>
|
||||
|
||||
{/* Waitlist Form - replaces button */}
|
||||
<motion.div variants={fadeInUp} custom={2} className="pt-4">
|
||||
{isAuthenticated ? (
|
||||
<MagneticButton strength={0.15}>
|
||||
<Link href="/dashboard">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-16 rounded-full bg-[hsl(var(--burgundy))] px-12 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 font-bold text-lg group"
|
||||
>
|
||||
Go to Dashboard
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Button>
|
||||
</Link>
|
||||
</MagneticButton>
|
||||
) : (
|
||||
<WaitlistForm />
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Social Proof Indicator */}
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
custom={3}
|
||||
className="flex flex-wrap items-center justify-center gap-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="w-2 h-2 rounded-full bg-green-500"
|
||||
/>
|
||||
<span className="font-semibold text-foreground">500+ joined this week</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 fill-current text-[hsl(var(--primary))]" />
|
||||
<span>Early access: <span className="font-semibold text-foreground">50% off for 6 months</span></span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Activity, TrendingUp, Zap, Shield } from 'lucide-react'
|
||||
|
||||
function AnimatedNumber({ value, suffix = '' }: { value: number; suffix?: string }) {
|
||||
const [displayValue, setDisplayValue] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const duration = 2000 // 2 seconds
|
||||
const steps = 60
|
||||
const increment = value / steps
|
||||
const stepDuration = duration / steps
|
||||
|
||||
let currentStep = 0
|
||||
const interval = setInterval(() => {
|
||||
currentStep++
|
||||
if (currentStep <= steps) {
|
||||
setDisplayValue(Math.floor(increment * currentStep))
|
||||
} else {
|
||||
setDisplayValue(value)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, stepDuration)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
|
||||
{displayValue.toLocaleString()}{suffix}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function FluctuatingNumber({ base, variance }: { base: number; variance: number }) {
|
||||
const [value, setValue] = useState(base)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const fluctuation = (Math.random() - 0.5) * variance
|
||||
setValue(base + fluctuation)
|
||||
}, 1500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [base, variance])
|
||||
|
||||
return (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))] tabular-nums">
|
||||
{Math.round(value)}ms
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LiveStatsBar() {
|
||||
const stats = [
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
label: 'Checks performed today',
|
||||
value: 2847,
|
||||
type: 'counter' as const
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
label: 'Changes detected this hour',
|
||||
value: 127,
|
||||
type: 'counter' as const
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
label: 'Uptime',
|
||||
value: '99.9%',
|
||||
type: 'static' as const
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
label: 'Avg response time',
|
||||
value: '< ',
|
||||
type: 'fluctuating' as const,
|
||||
base: 42,
|
||||
variance: 10
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="border-y border-border bg-gradient-to-r from-foreground/95 via-foreground to-foreground/95 py-8 overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-6">
|
||||
{/* Desktop: Grid */}
|
||||
<div className="hidden lg:grid lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="flex flex-col items-center text-center gap-3"
|
||||
>
|
||||
{/* Icon */}
|
||||
<motion.div
|
||||
className="flex items-center justify-center w-12 h-12 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{stat.icon}
|
||||
</motion.div>
|
||||
|
||||
{/* Value */}
|
||||
<div>
|
||||
{stat.type === 'counter' && typeof stat.value === 'number' && (
|
||||
<AnimatedNumber value={stat.value} />
|
||||
)}
|
||||
{stat.type === 'static' && (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}
|
||||
</span>
|
||||
)}
|
||||
{stat.type === 'fluctuating' && stat.base && stat.variance && (
|
||||
<span className="font-mono text-2xl lg:text-3xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className="text-xs font-medium text-white/90 uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Horizontal Scroll */}
|
||||
<div className="lg:hidden overflow-x-auto scrollbar-thin pb-2">
|
||||
<div className="flex gap-8 min-w-max px-4">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: i * 0.1, duration: 0.5 }}
|
||||
className="flex flex-col items-center text-center gap-3 min-w-[160px]"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-[hsl(var(--teal))]/10 text-[hsl(var(--teal))]">
|
||||
{stat.icon}
|
||||
</div>
|
||||
|
||||
{/* Value */}
|
||||
<div>
|
||||
{stat.type === 'counter' && typeof stat.value === 'number' && (
|
||||
<AnimatedNumber value={stat.value} />
|
||||
)}
|
||||
{stat.type === 'static' && (
|
||||
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}
|
||||
</span>
|
||||
)}
|
||||
{stat.type === 'fluctuating' && stat.base && stat.variance && (
|
||||
<span className="font-mono text-2xl font-bold text-[hsl(var(--teal))]">
|
||||
{stat.value}<FluctuatingNumber base={stat.base} variance={stat.variance} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<p className="text-[10px] font-medium text-white/90 uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
'use client'
|
||||
|
||||
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
|
||||
import { useRef, ReactNode } from 'react'
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
strength?: number
|
||||
}
|
||||
|
||||
export function MagneticButton({
|
||||
children,
|
||||
className = '',
|
||||
onClick,
|
||||
strength = 0.3
|
||||
}: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const x = useMotionValue(0)
|
||||
const y = useMotionValue(0)
|
||||
|
||||
const springConfig = { stiffness: 300, damping: 20 }
|
||||
const springX = useSpring(x, springConfig)
|
||||
const springY = useSpring(y, springConfig)
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return
|
||||
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
const centerX = rect.left + rect.width / 2
|
||||
const centerY = rect.top + rect.height / 2
|
||||
|
||||
const deltaX = (e.clientX - centerX) * strength
|
||||
const deltaY = (e.clientY - centerY) * strength
|
||||
|
||||
x.set(deltaX)
|
||||
y.set(deltaY)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
x.set(0)
|
||||
y.set(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
style={{ x: springX, y: springY }}
|
||||
className={`inline-block ${className}`}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionDividerProps {
|
||||
variant?: 'wave' | 'diagonal' | 'curve'
|
||||
fromColor?: string
|
||||
toColor?: string
|
||||
flip?: boolean
|
||||
}
|
||||
|
||||
export function SectionDivider({
|
||||
variant = 'wave',
|
||||
fromColor = 'section-bg-3',
|
||||
toColor = 'section-bg-4',
|
||||
flip = false
|
||||
}: SectionDividerProps) {
|
||||
if (variant === 'wave') {
|
||||
return (
|
||||
<div className={`w-full h-20 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M0,0 Q300,80 600,40 T1200,0 L1200,120 L0,120 Z"
|
||||
fill={`hsl(var(--${toColor}))`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'diagonal') {
|
||||
return (
|
||||
<div
|
||||
className={`w-full h-16 ${flip ? '-skew-y-2' : 'skew-y-2'}`}
|
||||
style={{
|
||||
background: `linear-gradient(to bottom right, hsl(var(--${fromColor})), hsl(var(--${toColor})))`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'curve') {
|
||||
return (
|
||||
<div className={`w-full h-24 -mt-1 overflow-hidden ${flip ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
className="w-full h-full"
|
||||
>
|
||||
<path
|
||||
d="M0,60 Q300,120 600,60 T1200,60 L1200,120 L0,120 Z"
|
||||
fill={`hsl(var(--${toColor}))`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FileCheck, Check } from 'lucide-react'
|
||||
|
||||
export function PolicyDemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--burgundy))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* Document Header */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="h-4 w-4 text-[hsl(var(--burgundy))]" />
|
||||
<span className="text-xs font-bold text-foreground">Terms of Service</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="px-2 py-0.5 rounded-full border text-[9px] font-bold"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.1)' : 'transparent',
|
||||
color: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--muted-foreground))'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{phase === 0 ? 'v2.1' : 'v2.2'}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Document Content */}
|
||||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg border-2 bg-white overflow-hidden"
|
||||
animate={{
|
||||
borderColor: phase === 1 ? 'hsl(var(--teal))' : 'hsl(var(--border))',
|
||||
boxShadow: phase === 1
|
||||
? '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
: '0 1px 3px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Section 4.2 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-[10px] font-bold text-[hsl(var(--primary))]">
|
||||
Section 4.2 - Data Retention
|
||||
</div>
|
||||
|
||||
{/* Text Lines */}
|
||||
<div className="space-y-1 text-[9px] text-muted-foreground leading-relaxed">
|
||||
<p>We will retain your personal data for</p>
|
||||
|
||||
{/* Changing text */}
|
||||
<motion.div
|
||||
className="relative rounded"
|
||||
layout
|
||||
>
|
||||
<motion.p
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--burgundy) / 0.15)' : 'transparent',
|
||||
paddingLeft: phase === 1 ? '4px' : '0px',
|
||||
paddingRight: phase === 1 ? '4px' : '0px',
|
||||
color: phase === 1 ? 'hsl(var(--burgundy))' : 'hsl(var(--muted-foreground))',
|
||||
fontWeight: phase === 1 ? 600 : 400
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="relative inline-block rounded"
|
||||
>
|
||||
{phase === 0 ? (
|
||||
'as long as necessary to fulfill purposes'
|
||||
) : (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
a minimum of 90 days after account deletion
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.p>
|
||||
|
||||
{/* Change highlight indicator */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="absolute -left-1 top-0 bottom-0 w-0.5 bg-[hsl(var(--burgundy))] rounded-full origin-left"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<p>outlined in our Privacy Policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Stats */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="pt-2 border-t border-border flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-[8px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded bg-green-500/20 border border-green-500" />
|
||||
+18 words
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded bg-red-500/20 border border-red-500" />
|
||||
-7 words
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Audit Trail Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-3 flex items-center gap-2 p-2 rounded-lg bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-5 h-5 rounded-full bg-[hsl(var(--teal))] text-white">
|
||||
<Check className="h-3 w-3" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[9px] font-bold text-[hsl(var(--teal))]">
|
||||
Audit trail saved
|
||||
</div>
|
||||
<div className="text-[8px] text-muted-foreground">
|
||||
Snapshot archived for compliance
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState } from 'react'
|
||||
import { TrendingDown, DollarSign } from 'lucide-react'
|
||||
|
||||
export function PricingComparison() {
|
||||
const [monitorCount, setMonitorCount] = useState(50)
|
||||
|
||||
// Pricing calculation logic
|
||||
const calculatePricing = (monitors: number) => {
|
||||
// Competitors: tiered pricing
|
||||
let competitorMin, competitorMax
|
||||
if (monitors <= 10) {
|
||||
competitorMin = 29
|
||||
competitorMax = 49
|
||||
} else if (monitors <= 50) {
|
||||
competitorMin = 79
|
||||
competitorMax = 129
|
||||
} else if (monitors <= 100) {
|
||||
competitorMin = 129
|
||||
competitorMax = 199
|
||||
} else {
|
||||
competitorMin = 199
|
||||
competitorMax = 299
|
||||
}
|
||||
|
||||
// Our pricing: simpler, fairer
|
||||
let ourPrice
|
||||
if (monitors <= 10) {
|
||||
ourPrice = 19
|
||||
} else if (monitors <= 50) {
|
||||
ourPrice = 49
|
||||
} else if (monitors <= 100) {
|
||||
ourPrice = 89
|
||||
} else {
|
||||
ourPrice = 149
|
||||
}
|
||||
|
||||
const competitorAvg = (competitorMin + competitorMax) / 2
|
||||
const savings = competitorAvg - ourPrice
|
||||
const savingsPercent = Math.round((savings / competitorAvg) * 100)
|
||||
|
||||
return {
|
||||
competitorMin,
|
||||
competitorMax,
|
||||
competitorAvg,
|
||||
ourPrice,
|
||||
savings,
|
||||
savingsPercent
|
||||
}
|
||||
}
|
||||
|
||||
const pricing = calculatePricing(monitorCount)
|
||||
|
||||
return (
|
||||
<section className="py-32 bg-gradient-to-b from-[hsl(var(--section-bg-6))] to-[hsl(var(--section-bg-3))] relative overflow-hidden">
|
||||
{/* Background Pattern - Enhanced Dot Grid */}
|
||||
<div className="absolute inset-0 opacity-8">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `radial-gradient(circle, hsl(var(--teal)) 1.5px, transparent 1.5px)`,
|
||||
backgroundSize: '30px 30px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-6 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/20 px-4 py-1.5 text-sm font-medium text-[hsl(var(--teal))] mb-6">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
Fair Pricing
|
||||
</div>
|
||||
<h2 className="text-4xl lg:text-5xl font-display font-bold text-foreground mb-6">
|
||||
See how much you{' '}
|
||||
<span className="text-[hsl(var(--teal))]">save</span>
|
||||
</h2>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Compare our transparent pricing with typical competitors. No hidden fees, no surprises.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Interactive Comparison Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="rounded-3xl border-2 border-border bg-card p-8 lg:p-12 shadow-2xl"
|
||||
>
|
||||
{/* Monitor Count Slider */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<label className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Number of Monitors
|
||||
</label>
|
||||
<motion.div
|
||||
key={monitorCount}
|
||||
initial={{ scale: 1.2 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-4xl font-bold text-foreground font-mono"
|
||||
>
|
||||
{monitorCount}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="200"
|
||||
step="5"
|
||||
value={monitorCount}
|
||||
onChange={(e) => setMonitorCount(Number(e.target.value))}
|
||||
className="w-full h-3 bg-secondary rounded-full appearance-none cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-6 [&::-webkit-slider-thumb]:h-6
|
||||
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[hsl(var(--teal))]
|
||||
[&::-webkit-slider-thumb]:shadow-lg [&::-webkit-slider-thumb]:cursor-grab
|
||||
[&::-webkit-slider-thumb]:active:cursor-grabbing [&::-webkit-slider-thumb]:hover:scale-110
|
||||
[&::-webkit-slider-thumb]:transition-transform
|
||||
[&::-moz-range-thumb]:w-6 [&::-moz-range-thumb]:h-6
|
||||
[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[hsl(var(--teal))]
|
||||
[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-lg
|
||||
[&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:active:cursor-grabbing"
|
||||
/>
|
||||
{/* Tick marks - positioned by percentage based on slider range (5-200) */}
|
||||
<div className="relative mt-2 h-4">
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: '0%', transform: 'translateX(0)' }}>5</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((50 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>50</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: `${((100 - 5) / (200 - 5)) * 100}%`, transform: 'translateX(-50%)' }}>100</span>
|
||||
<span className="absolute text-xs text-muted-foreground" style={{ left: '100%', transform: 'translateX(-100%)' }}>200</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Comparison Bars */}
|
||||
<div className="grid lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Competitors */}
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-muted-foreground uppercase tracking-wider">
|
||||
Typical Competitors
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<motion.div
|
||||
className="relative h-24 rounded-2xl bg-gradient-to-r from-red-500/10 to-red-500/20 border-2 border-red-500/30 overflow-hidden"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-red-500/20 to-red-500/40"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
key={`comp-${pricing.competitorMin}-${pricing.competitorMax}`}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-4xl font-bold text-red-700 font-mono"
|
||||
>
|
||||
${pricing.competitorMin}-{pricing.competitorMax}
|
||||
</motion.div>
|
||||
<div className="text-xs font-medium text-red-600">per month</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Us */}
|
||||
<motion.div
|
||||
layout
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-[hsl(var(--teal))] uppercase tracking-wider">
|
||||
Our Pricing
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<motion.div
|
||||
className="relative h-24 rounded-2xl bg-gradient-to-r from-[hsl(var(--teal))]/10 to-[hsl(var(--teal))]/20 border-2 border-[hsl(var(--teal))]/30 overflow-hidden"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-[hsl(var(--teal))]/20 to-[hsl(var(--teal))]/40"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: pricing.ourPrice / pricing.competitorMax }}
|
||||
transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }}
|
||||
style={{ transformOrigin: 'left' }}
|
||||
/>
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
key={`our-${pricing.ourPrice}`}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="text-5xl font-bold text-[hsl(var(--teal))] font-mono"
|
||||
>
|
||||
${pricing.ourPrice}
|
||||
</motion.div>
|
||||
<div className="text-xs font-medium text-[hsl(var(--teal))]">per month</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Savings Badge */}
|
||||
<motion.div
|
||||
key={`savings-${pricing.savings}`}
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="flex items-center justify-center gap-4 p-6 rounded-2xl bg-gradient-to-r from-[hsl(var(--primary))]/10 via-[hsl(var(--teal))]/10 to-[hsl(var(--burgundy))]/10 border-2 border-[hsl(var(--teal))]/30"
|
||||
>
|
||||
<TrendingDown className="h-8 w-8 text-[hsl(var(--teal))]" />
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-muted-foreground">You save</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
${Math.round(pricing.savings)}
|
||||
</span>
|
||||
<span className="text-xl text-muted-foreground">/month</span>
|
||||
<span className="ml-2 px-3 py-1 rounded-full bg-[hsl(var(--teal))]/20 text-sm font-bold text-[hsl(var(--teal))]">
|
||||
{pricing.savingsPercent}% off
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Fine Print */}
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
* Based on average pricing from Visualping, Distill.io, and similar competitors as of Jan 2026
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { TrendingDown, TrendingUp } from 'lucide-react'
|
||||
|
||||
export function SEODemoVisual() {
|
||||
const [phase, setPhase] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPhase(p => (p + 1) % 2)
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const oldMeta = "Best enterprise software for teams of all sizes. Try free for 30 days."
|
||||
const newMeta = "Best enterprise software for teams of all sizes. Try free for 30 days. Now with AI-powered analytics and real-time collaboration."
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[200px] bg-gradient-to-br from-background via-background to-[hsl(var(--teal))]/5 rounded-xl p-4 overflow-hidden">
|
||||
{/* SERP Result */}
|
||||
<div className="space-y-4">
|
||||
{/* Ranking Indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-mono text-muted-foreground">
|
||||
google.com/search
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex items-center gap-1 px-2 py-1 rounded-full bg-background border border-border"
|
||||
animate={{
|
||||
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--burgundy))',
|
||||
backgroundColor: phase === 0 ? 'hsl(var(--background))' : 'hsl(var(--burgundy) / 0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<span className="text-xs font-bold">Ranking:</span>
|
||||
<motion.span
|
||||
key={phase}
|
||||
initial={{ y: -10, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 10, opacity: 0 }}
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
#{phase === 0 ? '3' : '5'}
|
||||
</motion.span>
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
>
|
||||
<TrendingDown className="h-3 w-3 text-[hsl(var(--burgundy))]" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* SERP Snippet */}
|
||||
<motion.div
|
||||
className="space-y-2 p-3 rounded-lg bg-white border-2"
|
||||
animate={{
|
||||
borderColor: phase === 0 ? 'hsl(var(--border))' : 'hsl(var(--teal))',
|
||||
boxShadow: phase === 0
|
||||
? '0 1px 3px rgba(0,0,0,0.1)'
|
||||
: '0 0 20px hsl(var(--teal) / 0.3)'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-primary" />
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
competitor.com/product
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-sm font-bold text-[hsl(var(--primary))] line-clamp-1">
|
||||
Best Enterprise Software Solution 2026
|
||||
</h4>
|
||||
|
||||
{/* Meta Description with change highlighting */}
|
||||
<motion.p
|
||||
className="text-[11px] text-muted-foreground leading-relaxed relative"
|
||||
layout
|
||||
>
|
||||
<motion.span
|
||||
animate={{
|
||||
backgroundColor: phase === 1 ? 'hsl(var(--teal) / 0.2)' : 'transparent'
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-block rounded px-0.5"
|
||||
>
|
||||
{phase === 0 ? oldMeta : newMeta}
|
||||
</motion.span>
|
||||
|
||||
{/* Change indicator */}
|
||||
{phase === 1 && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -5 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="absolute -right-2 top-0 px-1.5 py-0.5 rounded bg-[hsl(var(--burgundy))] text-[8px] font-bold text-white"
|
||||
>
|
||||
NEW
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
{/* Alert Badge */}
|
||||
{phase === 1 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 5, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
className="flex items-center justify-center gap-2 text-[10px] font-bold text-[hsl(var(--teal))] uppercase tracking-wider"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[hsl(var(--teal))] animate-pulse" />
|
||||
Meta Description Changed
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Check, ArrowRight, Loader2, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function WaitlistForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [queuePosition, setQueuePosition] = useState(0)
|
||||
const [confetti, setConfetti] = useState<Array<{ id: number; x: number; y: number; rotation: number; color: string }>>([])
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
const triggerConfetti = () => {
|
||||
const colors = ['hsl(var(--primary))', 'hsl(var(--teal))', 'hsl(var(--burgundy))', '#fbbf24', '#f97316']
|
||||
const particles = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: Date.now() + i,
|
||||
x: 50 + (Math.random() - 0.5) * 40, // Center around 50%
|
||||
y: 50,
|
||||
rotation: Math.random() * 360,
|
||||
color: colors[Math.floor(Math.random() * colors.length)]
|
||||
}))
|
||||
setConfetti(particles)
|
||||
setTimeout(() => setConfetti([]), 3000)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!email) {
|
||||
setError('Please enter your email')
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError('Please enter a valid email')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
|
||||
// Generate a random queue position
|
||||
const position = Math.floor(Math.random() * 500) + 400
|
||||
|
||||
setQueuePosition(position)
|
||||
setIsSubmitting(false)
|
||||
setIsSuccess(true)
|
||||
triggerConfetti()
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative max-w-md mx-auto"
|
||||
>
|
||||
{/* Confetti */}
|
||||
{confetti.map(particle => (
|
||||
<motion.div
|
||||
key={particle.id}
|
||||
className="absolute w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: particle.color,
|
||||
left: `${particle.x}%`,
|
||||
top: `${particle.y}%`
|
||||
}}
|
||||
initial={{ opacity: 1, scale: 1 }}
|
||||
animate={{
|
||||
y: [-20, window.innerHeight / 4],
|
||||
x: [(Math.random() - 0.5) * 200],
|
||||
opacity: [1, 1, 0],
|
||||
rotate: [particle.rotation, particle.rotation + 720],
|
||||
scale: [1, 0.5, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2 + Math.random(),
|
||||
ease: [0.45, 0, 0.55, 1]
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Success Card */}
|
||||
<motion.div
|
||||
initial={{ y: 20 }}
|
||||
animate={{ y: 0 }}
|
||||
className="relative overflow-hidden rounded-3xl border-2 border-[hsl(var(--teal))] bg-white shadow-2xl shadow-[hsl(var(--teal))]/20 p-8 text-center"
|
||||
>
|
||||
{/* Animated background accent */}
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-[hsl(var(--primary))] via-[hsl(var(--teal))] to-[hsl(var(--burgundy))]"
|
||||
animate={{
|
||||
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
|
||||
}}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
style={{ backgroundSize: '200% 100%' }}
|
||||
/>
|
||||
|
||||
{/* Success Icon */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.2 }}
|
||||
className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-[hsl(var(--teal))]/10 border-2 border-[hsl(var(--teal))]"
|
||||
>
|
||||
<Check className="h-10 w-10 text-[hsl(var(--teal))]" strokeWidth={3} />
|
||||
</motion.div>
|
||||
|
||||
{/* Success Message */}
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-3 text-3xl font-display font-bold text-foreground"
|
||||
>
|
||||
You're on the list!
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mb-6 text-muted-foreground"
|
||||
>
|
||||
Check your inbox for confirmation
|
||||
</motion.p>
|
||||
|
||||
{/* Queue Position */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5, type: 'spring' }}
|
||||
className="inline-flex items-center gap-3 rounded-full bg-gradient-to-r from-[hsl(var(--primary))]/10 to-[hsl(var(--teal))]/10 border border-[hsl(var(--teal))]/30 px-6 py-3"
|
||||
>
|
||||
<Sparkles className="h-5 w-5 text-[hsl(var(--primary))]" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Your position
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
#{queuePosition}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Bonus Badge */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-full bg-[hsl(var(--burgundy))]/10 border border-[hsl(var(--burgundy))]/30 px-4 py-2"
|
||||
>
|
||||
<span className="text-sm font-bold text-[hsl(var(--burgundy))]">
|
||||
🎉 Early access: 50% off for 6 months
|
||||
</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="max-w-md mx-auto"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Email Input */}
|
||||
<motion.div
|
||||
className="flex-1 relative"
|
||||
animate={error ? { x: [-10, 10, -10, 10, 0] } : {}}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
placeholder="Enter your email"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full h-14 rounded-full px-6 text-base border-2 transition-all outline-none ${
|
||||
error
|
||||
? 'border-red-500 bg-red-50 focus:border-red-500 focus:ring-4 focus:ring-red-500/20'
|
||||
: 'border-border bg-background focus:border-[hsl(var(--primary))] focus:ring-4 focus:ring-[hsl(var(--primary))]/20'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute -bottom-6 left-4 text-xs font-medium text-red-500"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !email}
|
||||
size="lg"
|
||||
className="h-14 rounded-full bg-[hsl(var(--burgundy))] px-8 text-white hover:bg-[hsl(var(--burgundy))]/90 shadow-2xl shadow-[hsl(var(--burgundy))]/30 transition-all hover:scale-105 disabled:hover:scale-100 disabled:opacity-50 disabled:cursor-not-allowed font-bold text-base group whitespace-nowrap"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Joining...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Reserve Your Spot
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trust Signals Below Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-6 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<span>No credit card needed</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-[hsl(var(--teal))]" />
|
||||
<span>No spam, ever</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated } from '@/lib/auth'
|
||||
import { Sidebar } from '@/components/layout/sidebar'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, title, description }: DashboardLayoutProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
|
||||
{/* Main Content Area - responsive margin for sidebar */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Header */}
|
||||
{(title || description) && (
|
||||
<header className="sticky top-0 z-30 border-b border-border/50 bg-background/80 backdrop-blur-lg">
|
||||
<div className="px-8 py-6 pl-16 lg:pl-8">
|
||||
{title && <h1 className="text-2xl font-bold">{title}</h1>}
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Page Content - extra top padding on mobile for hamburger button */}
|
||||
<main className="p-8 pt-4 lg:pt-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { settingsAPI } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { clearAuth } from '@/lib/auth'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface NavItem {
|
||||
label: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Monitors',
|
||||
href: '/monitors',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Incidents',
|
||||
href: '/incidents',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Analytics',
|
||||
href: '/analytics',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar({ isOpen, onClose }: SidebarProps = {}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
// Fetch user settings to show current plan
|
||||
const { data: settingsData } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await settingsAPI.get()
|
||||
return response.settings || {}
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Default to stored user plan from localStorage if API fails or is loading
|
||||
const getStoredPlan = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) return JSON.parse(userStr).plan;
|
||||
} catch { return 'free'; }
|
||||
}
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// Capitalize plan name
|
||||
const planName = (settingsData?.plan || getStoredPlan() || 'free').charAt(0).toUpperCase() +
|
||||
(settingsData?.plan || getStoredPlan() || 'free').slice(1);
|
||||
|
||||
// Determine badge color
|
||||
const getBadgeVariant = (plan: string) => {
|
||||
switch (plan?.toLowerCase()) {
|
||||
case 'pro': return 'default'; // Primary color
|
||||
case 'business': return 'secondary';
|
||||
case 'enterprise': return 'destructive'; // Or another prominent color
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// Use controlled state if provided, otherwise use internal state
|
||||
const sidebarOpen = isOpen !== undefined ? isOpen : mobileOpen
|
||||
const handleClose = onClose || (() => setMobileOpen(false))
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/dashboard') {
|
||||
return pathname === '/dashboard'
|
||||
}
|
||||
if (href === '/monitors') {
|
||||
return pathname === '/monitors' || pathname?.startsWith('/monitors/')
|
||||
}
|
||||
return pathname === href || pathname?.startsWith(href + '/')
|
||||
}
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile sidebar after navigation
|
||||
if (window.innerWidth < 1024) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="fixed top-4 left-4 z-50 p-2 rounded-lg bg-card border border-border/50 shadow-md lg:hidden"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className="h-6 w-6 text-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={mobileOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden transition-opacity duration-300"
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 h-screen w-64 border-r border-border/50 bg-card/95 backdrop-blur-sm",
|
||||
"transition-transform duration-300 ease-in-out",
|
||||
"lg:translate-x-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-border/50 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<svg
|
||||
className="h-5 w-5 text-primary-foreground"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-foreground">WebMonitor</h1>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<Badge variant={getBadgeVariant(planName)} className="px-1.5 py-0 h-5 text-[10px] uppercase">
|
||||
{planName}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={handleNavClick}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200',
|
||||
active
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-secondary hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className={cn(active && 'text-primary')}>{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import React from 'react'
|
||||
|
||||
interface SparklineProps {
|
||||
data: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export function Sparkline({
|
||||
data,
|
||||
width = 120,
|
||||
height = 40,
|
||||
color = 'currentColor',
|
||||
strokeWidth = 2
|
||||
}: SparklineProps) {
|
||||
if (!data || data.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Normalize data to fit height
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1 // Avoid division by zero
|
||||
|
||||
// Calculate points
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width
|
||||
const y = height - ((value - min) / range) * height
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="overflow-visible">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary/10 text-primary",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive",
|
||||
success:
|
||||
"bg-green-100 text-green-700",
|
||||
warning:
|
||||
"bg-yellow-100 text-yellow-700",
|
||||
outline:
|
||||
"border border-border text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-md hover:bg-primary/90 hover:shadow-lg",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-background hover:bg-secondary hover:text-secondary-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-secondary hover:text-secondary-foreground",
|
||||
link:
|
||||
"text-primary underline-offset-4 hover:underline",
|
||||
success:
|
||||
"bg-success text-success-foreground shadow-sm hover:bg-success/90",
|
||||
warning:
|
||||
"bg-warning text-warning-foreground shadow-sm hover:bg-warning/90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-12 rounded-lg px-8 text-base",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, children, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { hover?: boolean }
|
||||
>(({ className, hover = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200",
|
||||
hover && "hover:shadow-md hover:border-primary/30 cursor-pointer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, label, error, hint, id, ...props }, ref) => {
|
||||
const inputId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type={type}
|
||||
id={inputId}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
options: { value: string | number; label: string }[]
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, label, error, hint, id, options, ...props }, ref) => {
|
||||
const selectId = id || React.useId()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="mb-1.5 block text-sm font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={cn(
|
||||
"flex h-10 w-full appearance-none rounded-lg border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-transparent disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')] bg-[length:1.25rem] bg-[right_0.5rem_center] bg-no-repeat pr-10",
|
||||
error && "border-destructive focus-visible:ring-destructive",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = "Select"
|
||||
|
||||
export { Select }
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface VisualSelectorProps {
|
||||
url: string
|
||||
onSelect: (selector: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an optimal CSS selector for an element
|
||||
*/
|
||||
function generateSelector(element: Element): string {
|
||||
// Try ID first
|
||||
if (element.id) {
|
||||
return `#${element.id}`
|
||||
}
|
||||
|
||||
// Try unique class combination
|
||||
if (element.classList.length > 0) {
|
||||
const classes = Array.from(element.classList)
|
||||
const classSelector = '.' + classes.join('.')
|
||||
if (document.querySelectorAll(classSelector).length === 1) {
|
||||
return classSelector
|
||||
}
|
||||
}
|
||||
|
||||
// Build path from parent elements
|
||||
const path: string[] = []
|
||||
let current: Element | null = element
|
||||
|
||||
while (current && current !== document.body) {
|
||||
let selector = current.tagName.toLowerCase()
|
||||
|
||||
if (current.id) {
|
||||
selector = `#${current.id}`
|
||||
path.unshift(selector)
|
||||
break
|
||||
}
|
||||
|
||||
if (current.classList.length > 0) {
|
||||
const significantClasses = Array.from(current.classList)
|
||||
.filter(c => !c.includes('hover') && !c.includes('active') && !c.includes('focus'))
|
||||
.slice(0, 2)
|
||||
if (significantClasses.length > 0) {
|
||||
selector += '.' + significantClasses.join('.')
|
||||
}
|
||||
}
|
||||
|
||||
// Add nth-child if needed for uniqueness
|
||||
const parent = current.parentElement
|
||||
if (parent) {
|
||||
const siblings = Array.from(parent.children).filter(
|
||||
c => c.tagName === current!.tagName
|
||||
)
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(current) + 1
|
||||
selector += `:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector)
|
||||
current = current.parentElement
|
||||
}
|
||||
|
||||
return path.join(' > ')
|
||||
}
|
||||
|
||||
export function VisualSelector({ url, onSelect, onClose }: VisualSelectorProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedSelector, setSelectedSelector] = useState('')
|
||||
const [testResult, setTestResult] = useState<{ count: number; success: boolean } | null>(null)
|
||||
const [proxyHtml, setProxyHtml] = useState<string | null>(null)
|
||||
|
||||
// Fetch page content through proxy
|
||||
useEffect(() => {
|
||||
async function fetchProxyContent() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch(`/api/proxy?url=${encodeURIComponent(url)}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load page')
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
setProxyHtml(html)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load page')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchProxyContent()
|
||||
}, [url])
|
||||
|
||||
// Handle clicks within the iframe
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentDocument) return
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
|
||||
// Inject selection styles
|
||||
const style = doc.createElement('style')
|
||||
style.textContent = `
|
||||
.visual-selector-hover {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
outline-offset: 2px;
|
||||
cursor: crosshair !important;
|
||||
}
|
||||
.visual-selector-selected {
|
||||
outline: 3px solid #22c55e !important;
|
||||
outline-offset: 2px;
|
||||
background-color: rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
`
|
||||
doc.head.appendChild(style)
|
||||
|
||||
// Add event listeners
|
||||
const handleMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target && target !== doc.body) {
|
||||
target.classList.add('visual-selector-hover')
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseOut = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target) {
|
||||
target.classList.remove('visual-selector-hover')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
if (!target || target === doc.body) return
|
||||
|
||||
// Remove previous selection
|
||||
doc.querySelectorAll('.visual-selector-selected').forEach(el => {
|
||||
el.classList.remove('visual-selector-selected')
|
||||
})
|
||||
|
||||
// Add selection to current element
|
||||
target.classList.add('visual-selector-selected')
|
||||
|
||||
// Generate and set selector
|
||||
const selector = generateSelector(target)
|
||||
setSelectedSelector(selector)
|
||||
|
||||
// Test the selector
|
||||
const matches = doc.querySelectorAll(selector)
|
||||
setTestResult({
|
||||
count: matches.length,
|
||||
success: matches.length === 1
|
||||
})
|
||||
}
|
||||
|
||||
doc.body.addEventListener('mouseover', handleMouseOver)
|
||||
doc.body.addEventListener('mouseout', handleMouseOut)
|
||||
doc.body.addEventListener('click', handleClick)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
doc.body.removeEventListener('mouseover', handleMouseOver)
|
||||
doc.body.removeEventListener('mouseout', handleMouseOut)
|
||||
doc.body.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSelector) {
|
||||
onSelect(selectedSelector)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestSelector = () => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe?.contentDocument || !selectedSelector) return
|
||||
|
||||
try {
|
||||
const matches = iframe.contentDocument.querySelectorAll(selectedSelector)
|
||||
setTestResult({
|
||||
count: matches.length,
|
||||
success: matches.length === 1
|
||||
})
|
||||
|
||||
// Highlight matches
|
||||
iframe.contentDocument.querySelectorAll('.visual-selector-selected').forEach(el => {
|
||||
el.classList.remove('visual-selector-selected')
|
||||
})
|
||||
matches.forEach(el => {
|
||||
el.classList.add('visual-selector-selected')
|
||||
})
|
||||
} catch {
|
||||
setTestResult({ count: 0, success: false })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Visual Element Selector</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click on an element to select it. The CSS selector will be generated automatically.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||
{/* URL display */}
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
Loading: {url}
|
||||
</div>
|
||||
|
||||
{/* Iframe container */}
|
||||
<div className="flex-1 relative border rounded-lg overflow-hidden bg-white">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-muted-foreground">Loading page...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||
<div className="text-center p-4">
|
||||
<p className="text-destructive font-medium">Failed to load page</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Note: Some sites may block embedding due to security policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyHtml && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={proxyHtml}
|
||||
className="w-full h-full"
|
||||
sandbox="allow-same-origin"
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ minHeight: '400px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selector controls */}
|
||||
<div className="flex-shrink-0 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={selectedSelector}
|
||||
onChange={(e) => setSelectedSelector(e.target.value)}
|
||||
placeholder="CSS selector will appear here..."
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button variant="outline" onClick={handleTestSelector} disabled={!selectedSelector}>
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`text-sm ${testResult.success ? 'text-green-600' : 'text-orange-600'}`}>
|
||||
{testResult.success
|
||||
? `✓ Selector matches exactly 1 element`
|
||||
: `⚠ Selector matches ${testResult.count} elements (should be 1)`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selectedSelector}>
|
||||
Use This Selector
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,90 +1,173 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/register', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Monitor API
|
||||
export const monitorAPI = {
|
||||
list: async () => {
|
||||
const response = await api.get('/monitors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const response = await api.get(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: any) => {
|
||||
const response = await api.post('/monitors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: any) => {
|
||||
const response = await api.put(`/monitors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
history: async (id: string, limit = 50) => {
|
||||
const response = await api.get(`/monitors/${id}/history`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
register: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/register', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
const response = await api.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
forgotPassword: async (email: string) => {
|
||||
const response = await api.post('/auth/forgot-password', { email });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resetPassword: async (token: string, newPassword: string) => {
|
||||
const response = await api.post('/auth/reset-password', { token, newPassword });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
verifyEmail: async (token: string) => {
|
||||
const response = await api.post('/auth/verify-email', { token });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
resendVerification: async (email: string) => {
|
||||
const response = await api.post('/auth/resend-verification', { email });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Monitor API
|
||||
export const monitorAPI = {
|
||||
list: async () => {
|
||||
const response = await api.get('/monitors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: string) => {
|
||||
const response = await api.get(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: any) => {
|
||||
const response = await api.post('/monitors', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: any) => {
|
||||
const response = await api.put(`/monitors/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: string) => {
|
||||
const response = await api.delete(`/monitors/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
check: async (id: string) => {
|
||||
const response = await api.post(`/monitors/${id}/check`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
history: async (id: string, limit = 50) => {
|
||||
const response = await api.get(`/monitors/${id}/history`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
snapshot: async (id: string, snapshotId: string) => {
|
||||
const response = await api.get(`/monitors/${id}/history/${snapshotId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportAuditTrail: async (id: string, format: 'json' | 'csv' = 'json') => {
|
||||
const token = localStorage.getItem('token');
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002';
|
||||
const url = `${API_URL}/api/monitors/${id}/export?format=${format}`;
|
||||
|
||||
// Create a hidden link and trigger download
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename = response.headers.get('Content-Disposition')?.split('filename="')[1]?.replace('"', '')
|
||||
|| `export.${format}`;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
},
|
||||
};
|
||||
|
||||
// Settings API
|
||||
export const settingsAPI = {
|
||||
get: async () => {
|
||||
const response = await api.get('/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changePassword: async (currentPassword: string, newPassword: string) => {
|
||||
const response = await api.post('/settings/change-password', {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateNotifications: async (data: {
|
||||
emailEnabled?: boolean;
|
||||
webhookUrl?: string | null;
|
||||
webhookEnabled?: boolean;
|
||||
slackWebhookUrl?: string | null;
|
||||
slackEnabled?: boolean;
|
||||
}) => {
|
||||
const response = await api.put('/settings/notifications', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteAccount: async (password: string) => {
|
||||
const response = await api.delete('/settings/account', {
|
||||
data: { password },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
|
|
|||