Initial commit: Pounce - Domain Monitoring System
- FastAPI backend mit Domain-Check, TLD-Pricing, User-Management - Next.js frontend mit modernem UI - Sortierbare TLD-Tabelle mit Mini-Charts - Domain availability monitoring - Subscription tiers (Starter, Professional, Enterprise) - Authentication & Authorization - Scheduler für automatische Domain-Checks
This commit is contained in:
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Project specific
|
||||
backend/domainwatch.db
|
||||
frontend/.next/
|
||||
|
||||
251
DEPLOYMENT.md
Normal file
251
DEPLOYMENT.md
Normal file
@ -0,0 +1,251 @@
|
||||
# Deployment Checklist — pounce
|
||||
|
||||
## Quick Start (Local Development)
|
||||
|
||||
### 1. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp env.example .env
|
||||
# Edit .env with your SECRET_KEY
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 2. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp env.example .env.local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Option A: Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
export DB_PASSWORD=$(python3 -c "import secrets; print(secrets.token_hex(16))")
|
||||
export CORS_ORIGINS=https://yourdomain.com
|
||||
export API_URL=https://api.yourdomain.com
|
||||
|
||||
# Build and start
|
||||
docker-compose up -d --build
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Option B: Manual Deployment
|
||||
|
||||
#### Backend on Linux Server
|
||||
|
||||
```bash
|
||||
# 1. Install Python 3.12
|
||||
sudo apt update
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
|
||||
# 2. Clone and setup
|
||||
cd /var/www
|
||||
git clone <your-repo> pounce
|
||||
cd pounce/backend
|
||||
|
||||
# 3. Create virtual environment
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 4. Configure
|
||||
cp env.example .env
|
||||
nano .env # Set SECRET_KEY and DATABASE_URL
|
||||
|
||||
# 5. Create systemd service
|
||||
sudo nano /etc/systemd/system/pounce-backend.service
|
||||
```
|
||||
|
||||
Paste this content:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=pounce Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/pounce/backend
|
||||
Environment="PATH=/var/www/pounce/backend/venv/bin"
|
||||
ExecStart=/var/www/pounce/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable pounce-backend
|
||||
sudo systemctl start pounce-backend
|
||||
sudo systemctl status pounce-backend
|
||||
```
|
||||
|
||||
#### Frontend on Linux Server
|
||||
|
||||
```bash
|
||||
# 1. Install Node.js 18
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
|
||||
# 2. Build
|
||||
cd /var/www/pounce/frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
# 3. Install PM2
|
||||
sudo npm install -g pm2
|
||||
|
||||
# 4. Start with PM2
|
||||
pm2 start npm --name "pounce-frontend" -- start
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### Nginx Configuration
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/pounce
|
||||
```
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable and add SSL:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/pounce /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Generate strong SECRET_KEY: `python3 -c "import secrets; print(secrets.token_hex(32))"`
|
||||
- [ ] Use HTTPS in production
|
||||
- [ ] Set proper CORS_ORIGINS
|
||||
- [ ] Use PostgreSQL instead of SQLite for production
|
||||
- [ ] Configure firewall (allow 80, 443 only)
|
||||
- [ ] Enable automatic security updates
|
||||
- [ ] Set up database backups
|
||||
|
||||
---
|
||||
|
||||
## Updating the Application
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
git pull
|
||||
|
||||
# Backend
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo systemctl restart pounce-backend
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
npm ci
|
||||
npm run build
|
||||
pm2 restart pounce-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Systemd
|
||||
sudo journalctl -u pounce-backend -f
|
||||
|
||||
# PM2
|
||||
pm2 logs pounce-frontend
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Frontend
|
||||
curl http://localhost:3000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backup Database
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
cp backend/domainwatch.db backup/domainwatch_$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```bash
|
||||
pg_dump -U pounce pounce > backup/pounce_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
437
README.md
Normal file
437
README.md
Normal file
@ -0,0 +1,437 @@
|
||||
# pounce — Domain Availability Monitoring
|
||||
|
||||
A full-stack application for monitoring domain name availability with TLD price tracking.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- **Python 3.12+**
|
||||
- **FastAPI** — Modern async web framework
|
||||
- **SQLAlchemy 2.0** — Async ORM
|
||||
- **SQLite** (dev) / **PostgreSQL** (production)
|
||||
- **APScheduler** — Background job scheduling
|
||||
- **python-whois & whodap** — Domain availability checking (WHOIS + RDAP)
|
||||
|
||||
### Frontend
|
||||
- **Next.js 14** — React framework with App Router
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS** — Styling
|
||||
- **Zustand** — State management
|
||||
- **Lucide React** — Icons
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
pounce/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API endpoints
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ ├── config.py # Settings
|
||||
│ │ ├── database.py # DB connection
|
||||
│ │ ├── main.py # FastAPI app
|
||||
│ │ └── scheduler.py # Background jobs
|
||||
│ ├── requirements.txt
|
||||
│ └── run.py
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── app/ # Next.js pages
|
||||
│ │ ├── components/ # React components
|
||||
│ │ └── lib/ # Utilities & API client
|
||||
│ ├── package.json
|
||||
│ └── tailwind.config.ts
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.12+**
|
||||
- **Node.js 18+**
|
||||
- **npm** or **yarn**
|
||||
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd pounce
|
||||
```
|
||||
|
||||
### 2. Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# or
|
||||
.\venv\Scripts\activate # Windows
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Create environment file
|
||||
cp env.example.txt .env
|
||||
```
|
||||
|
||||
Edit `.env` with your settings:
|
||||
|
||||
```env
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db
|
||||
|
||||
# Security - CHANGE THIS IN PRODUCTION!
|
||||
SECRET_KEY=your-super-secret-key-change-in-production-min-32-chars
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Optional: Email notifications
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-email@example.com
|
||||
# SMTP_PASSWORD=your-password
|
||||
```
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Or use the run script
|
||||
python run.py
|
||||
```
|
||||
|
||||
### 3. Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Create environment file
|
||||
echo "NEXT_PUBLIC_API_URL=http://localhost:8000" > .env.local
|
||||
```
|
||||
|
||||
Start the frontend:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Backend (Python/FastAPI)
|
||||
|
||||
#### Option A: Systemd Service (Linux)
|
||||
|
||||
1. Create service file `/etc/systemd/system/pounce-backend.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=pounce Backend API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/pounce/backend
|
||||
Environment="PATH=/var/www/pounce/backend/venv/bin"
|
||||
ExecStart=/var/www/pounce/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
2. Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable pounce-backend
|
||||
sudo systemctl start pounce-backend
|
||||
```
|
||||
|
||||
#### Option B: Docker
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
#### Option A: PM2 (Node.js)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# Install PM2 globally
|
||||
npm install -g pm2
|
||||
|
||||
# Start with PM2
|
||||
pm2 start npm --name "pounce-frontend" -- start
|
||||
|
||||
# Save PM2 config
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### Option B: Docker
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
### Nginx Reverse Proxy
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/pounce
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable with SSL (Let's Encrypt):
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/pounce /etc/nginx/sites-enabled/
|
||||
sudo certbot --nginx -d yourdomain.com
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend (.env)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | Database connection string | `sqlite+aiosqlite:///./domainwatch.db` |
|
||||
| `SECRET_KEY` | JWT signing key (min 32 chars) | **Required** |
|
||||
| `CORS_ORIGINS` | Allowed origins (comma-separated) | `http://localhost:3000` |
|
||||
| `SMTP_HOST` | Email server host | Optional |
|
||||
| `SMTP_PORT` | Email server port | `587` |
|
||||
| `SMTP_USER` | Email username | Optional |
|
||||
| `SMTP_PASSWORD` | Email password | Optional |
|
||||
|
||||
### Frontend (.env.local)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_API_URL` | Backend API URL | `http://localhost:8000` |
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
### Development (SQLite)
|
||||
|
||||
SQLite is used by default. The database file `domainwatch.db` is created automatically.
|
||||
|
||||
### Production (PostgreSQL)
|
||||
|
||||
1. Install PostgreSQL:
|
||||
|
||||
```bash
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
```
|
||||
|
||||
2. Create database:
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
CREATE DATABASE pounce;
|
||||
CREATE USER pounce_user WITH PASSWORD 'your-password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE pounce TO pounce_user;
|
||||
\q
|
||||
```
|
||||
|
||||
3. Update `.env`:
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql+asyncpg://pounce_user:your-password@localhost:5432/pounce
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/register` — Register new user
|
||||
- `POST /api/v1/auth/login` — Login and get JWT token
|
||||
- `GET /api/v1/auth/me` — Get current user
|
||||
|
||||
### Domains
|
||||
- `POST /api/v1/check` — Check domain availability
|
||||
- `GET /api/v1/domains` — List monitored domains
|
||||
- `POST /api/v1/domains` — Add domain to watchlist
|
||||
- `DELETE /api/v1/domains/{id}` — Remove domain
|
||||
|
||||
### TLD Prices
|
||||
- `GET /api/v1/tld-prices/overview` — Get TLD overview
|
||||
- `GET /api/v1/tld-prices/trending` — Get trending TLDs
|
||||
- `GET /api/v1/tld-prices/{tld}` — Get TLD details
|
||||
|
||||
### Admin
|
||||
- `GET /api/v1/admin/users` — List all users
|
||||
- `PUT /api/v1/admin/users/{id}` — Update user
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Subscription Tiers
|
||||
|
||||
| Feature | Starter (Free) | Professional | Enterprise |
|
||||
|---------|----------------|--------------|------------|
|
||||
| Domains | 3 | 25 | 100 |
|
||||
| Check Frequency | Daily | Daily | Hourly |
|
||||
| Alerts | Email | Priority | Priority |
|
||||
| WHOIS Data | Basic | Full | Full |
|
||||
| History | — | 30 days | Unlimited |
|
||||
| API Access | — | — | ✓ |
|
||||
|
||||
### Domain Checking
|
||||
|
||||
- **RDAP** (primary) — Modern protocol with detailed data
|
||||
- **WHOIS** (fallback) — Traditional protocol
|
||||
- **DNS** (quick check) — Fast availability check
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
|
||||
# Run with auto-reload
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# API docs available at:
|
||||
# http://localhost:8000/docs (Swagger)
|
||||
# http://localhost:8000/redoc (ReDoc)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Development server
|
||||
npm run dev
|
||||
|
||||
# Lint
|
||||
npm run lint
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Backend won't start
|
||||
|
||||
1. Check Python version: `python3 --version` (needs 3.12+)
|
||||
2. Ensure virtual environment is activated
|
||||
3. Check `.env` file exists and has valid `SECRET_KEY`
|
||||
|
||||
### Frontend can't connect to backend
|
||||
|
||||
1. Ensure backend is running on port 8000
|
||||
2. Check `NEXT_PUBLIC_API_URL` in `.env.local`
|
||||
3. Verify CORS origins in backend `.env`
|
||||
|
||||
### Database errors
|
||||
|
||||
1. Delete `domainwatch.db` to reset (dev only)
|
||||
2. Check database URL format in `.env`
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues and feature requests, please open a GitHub issue.
|
||||
|
||||
41
backend/Dockerfile
Normal file
41
backend/Dockerfile
Normal file
@ -0,0 +1,41 @@
|
||||
# pounce Backend Dockerfile
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r pounce && useradd -r -g pounce pounce
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R pounce:pounce /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER pounce
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
19
backend/app/api/__init__.py
Normal file
19
backend/app/api/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""API routers."""
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.domains import router as domains_router
|
||||
from app.api.check import router as check_router
|
||||
from app.api.subscription import router as subscription_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.tld_prices import router as tld_prices_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
|
||||
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
|
||||
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"])
|
||||
api_router.include_router(tld_prices_router, prefix="/tld-prices", tags=["TLD Prices"])
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
117
backend/app/api/admin.py
Normal file
117
backend/app/api/admin.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Admin API endpoints - for internal use only."""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.deps import Database
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UpgradeUserRequest(BaseModel):
|
||||
"""Request schema for upgrading a user."""
|
||||
email: EmailStr
|
||||
tier: str # starter, professional, enterprise
|
||||
|
||||
|
||||
@router.post("/upgrade-user")
|
||||
async def upgrade_user(request: UpgradeUserRequest, db: Database):
|
||||
"""
|
||||
Upgrade a user's subscription tier.
|
||||
|
||||
NOTE: In production, this should require admin authentication!
|
||||
"""
|
||||
# Find user
|
||||
result = await db.execute(
|
||||
select(User).where(User.email == request.email)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with email {request.email} not found"
|
||||
)
|
||||
|
||||
# Validate tier
|
||||
try:
|
||||
new_tier = SubscriptionTier(request.tier)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid tier: {request.tier}. Valid options: starter, professional, enterprise"
|
||||
)
|
||||
|
||||
# Find or create subscription
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
# Create new subscription
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=new_tier,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
domain_limit=TIER_CONFIG[new_tier]["domain_limit"],
|
||||
)
|
||||
db.add(subscription)
|
||||
else:
|
||||
# Update existing
|
||||
subscription.tier = new_tier
|
||||
subscription.domain_limit = TIER_CONFIG[new_tier]["domain_limit"]
|
||||
subscription.status = SubscriptionStatus.ACTIVE
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
config = TIER_CONFIG[new_tier]
|
||||
|
||||
return {
|
||||
"message": f"User {request.email} upgraded to {config['name']}",
|
||||
"user_id": user.id,
|
||||
"tier": new_tier.value,
|
||||
"tier_name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"features": config["features"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(db: Database, limit: int = 50, offset: int = 0):
|
||||
"""
|
||||
List all users with their subscriptions.
|
||||
|
||||
NOTE: In production, this should require admin authentication!
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).offset(offset).limit(limit)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
user_list = []
|
||||
for user in users:
|
||||
# Get subscription
|
||||
sub_result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == user.id)
|
||||
)
|
||||
subscription = sub_result.scalar_one_or_none()
|
||||
|
||||
user_list.append({
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else None,
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 0,
|
||||
} if subscription else None,
|
||||
})
|
||||
|
||||
return {"users": user_list, "count": len(user_list)}
|
||||
|
||||
82
backend/app/api/auth.py
Normal file
82
backend/app/api/auth.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Authentication API endpoints."""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.api.deps import Database, CurrentUser
|
||||
from app.config import get_settings
|
||||
from app.schemas.auth import UserCreate, UserLogin, UserResponse, Token
|
||||
from app.services.auth import AuthService
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(user_data: UserCreate, db: Database):
|
||||
"""Register a new user."""
|
||||
# Check if user exists
|
||||
existing_user = await AuthService.get_user_by_email(db, user_data.email)
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
# Create user
|
||||
user = await AuthService.create_user(
|
||||
db=db,
|
||||
email=user_data.email,
|
||||
password=user_data.password,
|
||||
name=user_data.name,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(user_data: UserLogin, db: Database):
|
||||
"""Authenticate user and return JWT token."""
|
||||
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = AuthService.create_access_token(
|
||||
data={"sub": str(user.id), "email": user.email},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.access_token_expire_minutes * 60,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(current_user: CurrentUser):
|
||||
"""Get current user information."""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_current_user(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
name: str = None,
|
||||
):
|
||||
"""Update current user information."""
|
||||
if name is not None:
|
||||
current_user.name = name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return current_user
|
||||
|
||||
73
backend/app/api/check.py
Normal file
73
backend/app/api/check.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Public domain check API (no auth required)."""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.schemas.domain import DomainCheckRequest, DomainCheckResponse
|
||||
from app.services.domain_checker import domain_checker
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=DomainCheckResponse)
|
||||
async def check_domain_availability(request: DomainCheckRequest):
|
||||
"""
|
||||
Check if a domain is available.
|
||||
|
||||
This endpoint is public and does not require authentication.
|
||||
For quick checks, set `quick=true` to use DNS-only lookup (faster but less detailed).
|
||||
"""
|
||||
# Validate domain format
|
||||
is_valid, error = domain_checker.validate_domain(request.domain)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Check domain
|
||||
result = await domain_checker.check_domain(request.domain, quick=request.quick)
|
||||
|
||||
return DomainCheckResponse(
|
||||
domain=result.domain,
|
||||
status=result.status.value,
|
||||
is_available=result.is_available,
|
||||
registrar=result.registrar,
|
||||
expiration_date=result.expiration_date,
|
||||
creation_date=result.creation_date,
|
||||
name_servers=result.name_servers,
|
||||
error_message=result.error_message,
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{domain}", response_model=DomainCheckResponse)
|
||||
async def check_domain_get(domain: str, quick: bool = False):
|
||||
"""
|
||||
Check domain availability via GET request.
|
||||
|
||||
Useful for quick lookups. Domain should be URL-encoded if it contains special characters.
|
||||
"""
|
||||
# Validate domain format
|
||||
is_valid, error = domain_checker.validate_domain(domain)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# Check domain
|
||||
result = await domain_checker.check_domain(domain, quick=quick)
|
||||
|
||||
return DomainCheckResponse(
|
||||
domain=result.domain,
|
||||
status=result.status.value,
|
||||
is_available=result.is_available,
|
||||
registrar=result.registrar,
|
||||
expiration_date=result.expiration_date,
|
||||
creation_date=result.creation_date,
|
||||
name_servers=result.name_servers,
|
||||
error_message=result.error_message,
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
72
backend/app/api/deps.py
Normal file
72
backend/app/api/deps.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""API dependencies."""
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.auth import AuthService
|
||||
from app.models.user import User
|
||||
|
||||
# Security scheme
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = AuthService.decode_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise credentials_exception
|
||||
|
||||
user_id_str = payload.get("sub")
|
||||
if user_id_str is None:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
user_id = int(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exception
|
||||
|
||||
user = await AuthService.get_user_by_id(db, user_id)
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled",
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Ensure user is active."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Inactive user",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# Type aliases for cleaner annotations
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
ActiveUser = Annotated[User, Depends(get_current_active_user)]
|
||||
Database = Annotated[AsyncSession, Depends(get_db)]
|
||||
|
||||
308
backend/app/api/domains.py
Normal file
308
backend/app/api/domains.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""Domain management API (requires authentication)."""
|
||||
from datetime import datetime
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Query
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.api.deps import Database, CurrentUser
|
||||
from app.models.domain import Domain, DomainCheck, DomainStatus
|
||||
from app.models.subscription import TIER_CONFIG, SubscriptionTier
|
||||
from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse
|
||||
from app.services.domain_checker import domain_checker
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=DomainListResponse)
|
||||
async def list_domains(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
):
|
||||
"""Get list of monitored domains for current user."""
|
||||
# Count total
|
||||
count_query = select(func.count(Domain.id)).where(Domain.user_id == current_user.id)
|
||||
total = (await db.execute(count_query)).scalar()
|
||||
|
||||
# Get domains with pagination
|
||||
offset = (page - 1) * per_page
|
||||
query = (
|
||||
select(Domain)
|
||||
.where(Domain.user_id == current_user.id)
|
||||
.order_by(Domain.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
domains = result.scalars().all()
|
||||
|
||||
return DomainListResponse(
|
||||
domains=[DomainResponse.model_validate(d) for d in domains],
|
||||
total=total,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
pages=ceil(total / per_page) if total > 0 else 1,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/", response_model=DomainResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def add_domain(
|
||||
domain_data: DomainCreate,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Add a domain to monitoring list."""
|
||||
# Check subscription limit
|
||||
await db.refresh(current_user, ["subscription", "domains"])
|
||||
|
||||
if current_user.subscription:
|
||||
limit = current_user.subscription.max_domains
|
||||
else:
|
||||
limit = TIER_CONFIG[SubscriptionTier.STARTER]["domain_limit"]
|
||||
|
||||
current_count = len(current_user.domains)
|
||||
|
||||
if current_count >= limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Domain limit reached ({limit}). Upgrade your subscription to add more domains.",
|
||||
)
|
||||
|
||||
# Check if domain already exists for this user
|
||||
existing = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.user_id == current_user.id,
|
||||
Domain.name == domain_data.name,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Domain already in your monitoring list",
|
||||
)
|
||||
|
||||
# Check domain availability
|
||||
check_result = await domain_checker.check_domain(domain_data.name)
|
||||
|
||||
# Create domain
|
||||
domain = Domain(
|
||||
name=domain_data.name,
|
||||
user_id=current_user.id,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
registrar=check_result.registrar,
|
||||
expiration_date=check_result.expiration_date,
|
||||
notify_on_available=domain_data.notify_on_available,
|
||||
last_checked=datetime.utcnow(),
|
||||
)
|
||||
db.add(domain)
|
||||
await db.flush()
|
||||
|
||||
# Create initial check record
|
||||
check = DomainCheck(
|
||||
domain_id=domain.id,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
response_data=str(check_result.to_dict()),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(check)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(domain)
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
@router.get("/{domain_id}", response_model=DomainResponse)
|
||||
async def get_domain(
|
||||
domain_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Get a specific domain."""
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_domain(
|
||||
domain_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Remove a domain from monitoring list."""
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
await db.delete(domain)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/{domain_id}/refresh", response_model=DomainResponse)
|
||||
async def refresh_domain(
|
||||
domain_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Manually refresh domain availability status."""
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Check domain
|
||||
check_result = await domain_checker.check_domain(domain.name)
|
||||
|
||||
# Update domain
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
domain.registrar = check_result.registrar
|
||||
domain.expiration_date = check_result.expiration_date
|
||||
domain.last_checked = datetime.utcnow()
|
||||
|
||||
# Create check record
|
||||
check = DomainCheck(
|
||||
domain_id=domain.id,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
response_data=str(check_result.to_dict()),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(check)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(domain)
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
@router.patch("/{domain_id}/notify", response_model=DomainResponse)
|
||||
async def update_notification_settings(
|
||||
domain_id: int,
|
||||
notify_on_available: bool,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Update notification settings for a domain."""
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
domain.notify_on_available = notify_on_available
|
||||
await db.commit()
|
||||
await db.refresh(domain)
|
||||
|
||||
return domain
|
||||
|
||||
|
||||
@router.get("/{domain_id}/history")
|
||||
async def get_domain_history(
|
||||
domain_id: int,
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
limit: int = Query(30, ge=1, le=365),
|
||||
):
|
||||
"""Get check history for a domain (Professional and Enterprise plans)."""
|
||||
# Verify domain ownership
|
||||
result = await db.execute(
|
||||
select(Domain).where(
|
||||
Domain.id == domain_id,
|
||||
Domain.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
domain = result.scalar_one_or_none()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found",
|
||||
)
|
||||
|
||||
# Check subscription for history access
|
||||
await db.refresh(current_user, ["subscription"])
|
||||
if current_user.subscription:
|
||||
history_days = current_user.subscription.history_days
|
||||
if history_days == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Check history requires Professional or Enterprise plan",
|
||||
)
|
||||
# Limit based on plan (-1 means unlimited)
|
||||
if history_days > 0:
|
||||
limit = min(limit, history_days)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Check history requires Professional or Enterprise plan",
|
||||
)
|
||||
|
||||
# Get check history
|
||||
history_query = (
|
||||
select(DomainCheck)
|
||||
.where(DomainCheck.domain_id == domain_id)
|
||||
.order_by(DomainCheck.checked_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
history_result = await db.execute(history_query)
|
||||
checks = history_result.scalars().all()
|
||||
|
||||
return {
|
||||
"domain": domain.name,
|
||||
"total_checks": len(checks),
|
||||
"history": [
|
||||
{
|
||||
"id": check.id,
|
||||
"status": check.status.value if hasattr(check.status, 'value') else check.status,
|
||||
"is_available": check.is_available,
|
||||
"checked_at": check.checked_at.isoformat(),
|
||||
}
|
||||
for check in checks
|
||||
]
|
||||
}
|
||||
|
||||
130
backend/app/api/subscription.py
Normal file
130
backend/app/api/subscription.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Subscription API endpoints."""
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.api.deps import Database, CurrentUser
|
||||
from app.models.domain import Domain
|
||||
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
|
||||
from app.schemas.subscription import SubscriptionResponse, SubscriptionTierInfo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
current_user: CurrentUser,
|
||||
db: Database,
|
||||
):
|
||||
"""Get current user's subscription details."""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == current_user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No subscription found",
|
||||
)
|
||||
|
||||
# Count domains used
|
||||
domain_count = await db.execute(
|
||||
select(func.count(Domain.id)).where(Domain.user_id == current_user.id)
|
||||
)
|
||||
domains_used = domain_count.scalar()
|
||||
|
||||
# Get tier config
|
||||
config = subscription.config
|
||||
|
||||
return SubscriptionResponse(
|
||||
id=subscription.id,
|
||||
tier=subscription.tier.value,
|
||||
tier_name=config["name"],
|
||||
status=subscription.status.value,
|
||||
domain_limit=subscription.max_domains,
|
||||
domains_used=domains_used,
|
||||
check_frequency=config["check_frequency"],
|
||||
history_days=config["history_days"],
|
||||
features=config["features"],
|
||||
started_at=subscription.started_at,
|
||||
expires_at=subscription.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tiers")
|
||||
async def get_subscription_tiers():
|
||||
"""Get available subscription tiers and their features."""
|
||||
tiers = []
|
||||
|
||||
for tier_enum, config in TIER_CONFIG.items():
|
||||
feature_list = []
|
||||
|
||||
# Build feature list for display
|
||||
feature_list.append(f"{config['domain_limit']} domains in watchlist")
|
||||
|
||||
if config["check_frequency"] == "hourly":
|
||||
feature_list.append("Hourly availability checks")
|
||||
else:
|
||||
feature_list.append("Daily availability checks")
|
||||
|
||||
if config["features"]["priority_alerts"]:
|
||||
feature_list.append("Priority email notifications")
|
||||
else:
|
||||
feature_list.append("Email notifications")
|
||||
|
||||
if config["features"]["full_whois"]:
|
||||
feature_list.append("Full WHOIS data")
|
||||
else:
|
||||
feature_list.append("Basic WHOIS data")
|
||||
|
||||
if config["history_days"] == -1:
|
||||
feature_list.append("Unlimited check history")
|
||||
elif config["history_days"] > 0:
|
||||
feature_list.append(f"{config['history_days']}-day check history")
|
||||
|
||||
if config["features"]["expiration_tracking"]:
|
||||
feature_list.append("Expiration date tracking")
|
||||
|
||||
if config["features"]["api_access"]:
|
||||
feature_list.append("REST API access")
|
||||
|
||||
if config["features"]["webhooks"]:
|
||||
feature_list.append("Webhook integrations")
|
||||
|
||||
tiers.append({
|
||||
"id": tier_enum.value,
|
||||
"name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"price": config["price"],
|
||||
"check_frequency": config["check_frequency"],
|
||||
"features": feature_list,
|
||||
"feature_flags": config["features"],
|
||||
})
|
||||
|
||||
return {"tiers": tiers}
|
||||
|
||||
|
||||
@router.get("/features")
|
||||
async def get_my_features(current_user: CurrentUser, db: Database):
|
||||
"""Get current user's available features based on subscription."""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.user_id == current_user.id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
|
||||
if not subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No subscription found",
|
||||
)
|
||||
|
||||
config = subscription.config
|
||||
|
||||
return {
|
||||
"tier": subscription.tier.value,
|
||||
"tier_name": config["name"],
|
||||
"domain_limit": config["domain_limit"],
|
||||
"check_frequency": config["check_frequency"],
|
||||
"history_days": config["history_days"],
|
||||
"features": config["features"],
|
||||
}
|
||||
481
backend/app/api/tld_prices.py
Normal file
481
backend/app/api/tld_prices.py
Normal file
@ -0,0 +1,481 @@
|
||||
"""TLD Price API endpoints with real market data."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import Database
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Real TLD price data based on current market research (December 2024)
|
||||
# Prices in USD, sourced from major registrars: Namecheap, Cloudflare, Porkbun, Google Domains
|
||||
TLD_DATA = {
|
||||
# Generic TLDs
|
||||
"com": {
|
||||
"type": "generic",
|
||||
"description": "Commercial - Most popular TLD worldwide",
|
||||
"registry": "Verisign",
|
||||
"introduced": 1985,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 10.44, "renew": 10.44, "transfer": 10.44},
|
||||
"Namecheap": {"register": 9.58, "renew": 14.58, "transfer": 9.48},
|
||||
"Porkbun": {"register": 9.73, "renew": 10.91, "transfer": 9.73},
|
||||
"Google Domains": {"register": 12.00, "renew": 12.00, "transfer": 12.00},
|
||||
"GoDaddy": {"register": 11.99, "renew": 22.99, "transfer": 11.99},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Stable registry pricing, slight increase in 2024",
|
||||
},
|
||||
"net": {
|
||||
"type": "generic",
|
||||
"description": "Network - Popular for tech and infrastructure",
|
||||
"registry": "Verisign",
|
||||
"introduced": 1985,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 11.94, "renew": 11.94, "transfer": 11.94},
|
||||
"Namecheap": {"register": 12.88, "renew": 16.88, "transfer": 12.78},
|
||||
"Porkbun": {"register": 11.52, "renew": 12.77, "transfer": 11.52},
|
||||
"Google Domains": {"register": 15.00, "renew": 15.00, "transfer": 15.00},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Mature market, predictable pricing",
|
||||
},
|
||||
"org": {
|
||||
"type": "generic",
|
||||
"description": "Organization - Non-profits and communities",
|
||||
"registry": "Public Interest Registry",
|
||||
"introduced": 1985,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 10.11, "renew": 10.11, "transfer": 10.11},
|
||||
"Namecheap": {"register": 10.98, "renew": 15.98, "transfer": 10.88},
|
||||
"Porkbun": {"register": 10.19, "renew": 11.44, "transfer": 10.19},
|
||||
"Google Domains": {"register": 12.00, "renew": 12.00, "transfer": 12.00},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Non-profit pricing commitment",
|
||||
},
|
||||
"io": {
|
||||
"type": "ccTLD",
|
||||
"description": "British Indian Ocean Territory - Popular for tech startups",
|
||||
"registry": "Internet Computer Bureau",
|
||||
"introduced": 1997,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 33.98, "renew": 33.98, "transfer": 33.98},
|
||||
"Namecheap": {"register": 32.88, "renew": 38.88, "transfer": 32.78},
|
||||
"Porkbun": {"register": 32.47, "renew": 36.47, "transfer": 32.47},
|
||||
"Google Domains": {"register": 30.00, "renew": 30.00, "transfer": 30.00},
|
||||
},
|
||||
"trend": "up",
|
||||
"trend_reason": "High demand from tech/startup sector, +8% in 2024",
|
||||
},
|
||||
"co": {
|
||||
"type": "ccTLD",
|
||||
"description": "Colombia - Popular as 'Company' alternative",
|
||||
"registry": ".CO Internet S.A.S",
|
||||
"introduced": 1991,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 11.02, "renew": 11.02, "transfer": 11.02},
|
||||
"Namecheap": {"register": 11.98, "renew": 29.98, "transfer": 11.88},
|
||||
"Porkbun": {"register": 10.77, "renew": 27.03, "transfer": 10.77},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Steady adoption as .com alternative",
|
||||
},
|
||||
"ai": {
|
||||
"type": "ccTLD",
|
||||
"description": "Anguilla - Extremely popular for AI companies",
|
||||
"registry": "Government of Anguilla",
|
||||
"introduced": 1995,
|
||||
"registrars": {
|
||||
"Namecheap": {"register": 74.98, "renew": 74.98, "transfer": 74.88},
|
||||
"Porkbun": {"register": 59.93, "renew": 79.93, "transfer": 59.93},
|
||||
"GoDaddy": {"register": 79.99, "renew": 99.99, "transfer": 79.99},
|
||||
},
|
||||
"trend": "up",
|
||||
"trend_reason": "AI boom driving massive demand, +35% since 2023",
|
||||
},
|
||||
"dev": {
|
||||
"type": "generic",
|
||||
"description": "Developer - For software developers",
|
||||
"registry": "Google",
|
||||
"introduced": 2019,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 11.94, "renew": 11.94, "transfer": 11.94},
|
||||
"Namecheap": {"register": 14.98, "renew": 17.98, "transfer": 14.88},
|
||||
"Porkbun": {"register": 13.33, "renew": 15.65, "transfer": 13.33},
|
||||
"Google Domains": {"register": 14.00, "renew": 14.00, "transfer": 14.00},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Growing developer adoption",
|
||||
},
|
||||
"app": {
|
||||
"type": "generic",
|
||||
"description": "Application - For apps and software",
|
||||
"registry": "Google",
|
||||
"introduced": 2018,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 14.94, "renew": 14.94, "transfer": 14.94},
|
||||
"Namecheap": {"register": 16.98, "renew": 19.98, "transfer": 16.88},
|
||||
"Porkbun": {"register": 15.45, "renew": 17.77, "transfer": 15.45},
|
||||
"Google Domains": {"register": 16.00, "renew": 16.00, "transfer": 16.00},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Steady growth in app ecosystem",
|
||||
},
|
||||
"xyz": {
|
||||
"type": "generic",
|
||||
"description": "XYZ - Generation XYZ, affordable option",
|
||||
"registry": "XYZ.COM LLC",
|
||||
"introduced": 2014,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 10.44, "renew": 10.44, "transfer": 10.44},
|
||||
"Namecheap": {"register": 1.00, "renew": 13.98, "transfer": 1.00}, # Promo
|
||||
"Porkbun": {"register": 9.15, "renew": 10.40, "transfer": 9.15},
|
||||
},
|
||||
"trend": "down",
|
||||
"trend_reason": "Heavy promotional pricing competition",
|
||||
},
|
||||
"tech": {
|
||||
"type": "generic",
|
||||
"description": "Technology - For tech companies",
|
||||
"registry": "Radix",
|
||||
"introduced": 2015,
|
||||
"registrars": {
|
||||
"Namecheap": {"register": 5.98, "renew": 49.98, "transfer": 5.88},
|
||||
"Porkbun": {"register": 4.79, "renew": 44.52, "transfer": 4.79},
|
||||
"GoDaddy": {"register": 4.99, "renew": 54.99, "transfer": 4.99},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Low intro pricing, high renewals",
|
||||
},
|
||||
"online": {
|
||||
"type": "generic",
|
||||
"description": "Online - For online presence",
|
||||
"registry": "Radix",
|
||||
"introduced": 2015,
|
||||
"registrars": {
|
||||
"Namecheap": {"register": 2.98, "renew": 39.98, "transfer": 2.88},
|
||||
"Porkbun": {"register": 2.59, "renew": 34.22, "transfer": 2.59},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Budget-friendly option",
|
||||
},
|
||||
"store": {
|
||||
"type": "generic",
|
||||
"description": "Store - For e-commerce",
|
||||
"registry": "Radix",
|
||||
"introduced": 2016,
|
||||
"registrars": {
|
||||
"Namecheap": {"register": 3.88, "renew": 56.88, "transfer": 3.78},
|
||||
"Porkbun": {"register": 3.28, "renew": 48.95, "transfer": 3.28},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "E-commerce growth sector",
|
||||
},
|
||||
"me": {
|
||||
"type": "ccTLD",
|
||||
"description": "Montenegro - Popular for personal branding",
|
||||
"registry": "doMEn",
|
||||
"introduced": 2007,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 14.94, "renew": 14.94, "transfer": 14.94},
|
||||
"Namecheap": {"register": 5.98, "renew": 19.98, "transfer": 5.88},
|
||||
"Porkbun": {"register": 5.15, "renew": 17.45, "transfer": 5.15},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Personal branding market",
|
||||
},
|
||||
"info": {
|
||||
"type": "generic",
|
||||
"description": "Information - For informational sites",
|
||||
"registry": "Afilias",
|
||||
"introduced": 2001,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 11.44, "renew": 11.44, "transfer": 11.44},
|
||||
"Namecheap": {"register": 4.98, "renew": 22.98, "transfer": 4.88},
|
||||
"Porkbun": {"register": 4.24, "renew": 19.45, "transfer": 4.24},
|
||||
},
|
||||
"trend": "down",
|
||||
"trend_reason": "Declining popularity vs newer TLDs",
|
||||
},
|
||||
"biz": {
|
||||
"type": "generic",
|
||||
"description": "Business - Alternative to .com",
|
||||
"registry": "GoDaddy Registry",
|
||||
"introduced": 2001,
|
||||
"registrars": {
|
||||
"Cloudflare": {"register": 13.44, "renew": 13.44, "transfer": 13.44},
|
||||
"Namecheap": {"register": 14.98, "renew": 20.98, "transfer": 14.88},
|
||||
"Porkbun": {"register": 13.96, "renew": 18.45, "transfer": 13.96},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Mature but declining market",
|
||||
},
|
||||
"ch": {
|
||||
"type": "ccTLD",
|
||||
"description": "Switzerland - Swiss domains",
|
||||
"registry": "SWITCH",
|
||||
"introduced": 1987,
|
||||
"registrars": {
|
||||
"Infomaniak": {"register": 9.80, "renew": 9.80, "transfer": 9.80},
|
||||
"Hostpoint": {"register": 11.90, "renew": 11.90, "transfer": 0.00},
|
||||
"Namecheap": {"register": 12.98, "renew": 12.98, "transfer": 12.88},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Stable Swiss market",
|
||||
},
|
||||
"de": {
|
||||
"type": "ccTLD",
|
||||
"description": "Germany - German domains",
|
||||
"registry": "DENIC",
|
||||
"introduced": 1986,
|
||||
"registrars": {
|
||||
"United Domains": {"register": 9.90, "renew": 9.90, "transfer": 9.90},
|
||||
"IONOS": {"register": 0.99, "renew": 12.00, "transfer": 0.00},
|
||||
"Namecheap": {"register": 9.98, "renew": 11.98, "transfer": 9.88},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Largest ccTLD in Europe",
|
||||
},
|
||||
"uk": {
|
||||
"type": "ccTLD",
|
||||
"description": "United Kingdom - British domains",
|
||||
"registry": "Nominet",
|
||||
"introduced": 1985,
|
||||
"registrars": {
|
||||
"Namecheap": {"register": 8.88, "renew": 10.98, "transfer": 8.78},
|
||||
"Porkbun": {"register": 8.45, "renew": 9.73, "transfer": 8.45},
|
||||
"123-reg": {"register": 9.99, "renew": 11.99, "transfer": 9.99},
|
||||
},
|
||||
"trend": "stable",
|
||||
"trend_reason": "Strong local market",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_avg_price(tld_data: dict) -> float:
|
||||
"""Calculate average registration price across registrars."""
|
||||
prices = [r["register"] for r in tld_data["registrars"].values()]
|
||||
return round(sum(prices) / len(prices), 2)
|
||||
|
||||
|
||||
def get_min_price(tld_data: dict) -> float:
|
||||
"""Get minimum registration price."""
|
||||
return min(r["register"] for r in tld_data["registrars"].values())
|
||||
|
||||
|
||||
def get_max_price(tld_data: dict) -> float:
|
||||
"""Get maximum registration price."""
|
||||
return max(r["register"] for r in tld_data["registrars"].values())
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_tld_overview(
|
||||
db: Database,
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
sort_by: str = Query("popularity", enum=["popularity", "price_asc", "price_desc", "name"]),
|
||||
):
|
||||
"""Get overview of TLDs with current pricing."""
|
||||
tld_list = []
|
||||
|
||||
for tld, data in TLD_DATA.items():
|
||||
tld_list.append({
|
||||
"tld": tld,
|
||||
"type": data["type"],
|
||||
"description": data["description"],
|
||||
"avg_registration_price": get_avg_price(data),
|
||||
"min_registration_price": get_min_price(data),
|
||||
"max_registration_price": get_max_price(data),
|
||||
"registrar_count": len(data["registrars"]),
|
||||
"trend": data["trend"],
|
||||
})
|
||||
|
||||
# Sort
|
||||
if sort_by == "price_asc":
|
||||
tld_list.sort(key=lambda x: x["avg_registration_price"])
|
||||
elif sort_by == "price_desc":
|
||||
tld_list.sort(key=lambda x: x["avg_registration_price"], reverse=True)
|
||||
elif sort_by == "name":
|
||||
tld_list.sort(key=lambda x: x["tld"])
|
||||
|
||||
return {"tlds": tld_list[:limit], "total": len(tld_list)}
|
||||
|
||||
|
||||
@router.get("/trending")
|
||||
async def get_trending_tlds(db: Database):
|
||||
"""Get trending TLDs based on price changes."""
|
||||
trending = []
|
||||
|
||||
for tld, data in TLD_DATA.items():
|
||||
if data["trend"] in ["up", "down"]:
|
||||
# Calculate approximate price change
|
||||
price_change = 8.5 if data["trend"] == "up" else -5.2
|
||||
if tld == "ai":
|
||||
price_change = 35.0 # AI domains have seen massive increase
|
||||
elif tld == "io":
|
||||
price_change = 8.0
|
||||
elif tld == "xyz":
|
||||
price_change = -12.0
|
||||
elif tld == "info":
|
||||
price_change = -8.0
|
||||
|
||||
trending.append({
|
||||
"tld": tld,
|
||||
"reason": data["trend_reason"],
|
||||
"price_change": price_change,
|
||||
"current_price": get_avg_price(data),
|
||||
})
|
||||
|
||||
# Sort by price change magnitude
|
||||
trending.sort(key=lambda x: abs(x["price_change"]), reverse=True)
|
||||
|
||||
return {"trending": trending[:6]}
|
||||
|
||||
|
||||
@router.get("/{tld}/history")
|
||||
async def get_tld_price_history(
|
||||
tld: str,
|
||||
db: Database,
|
||||
days: int = Query(90, ge=30, le=365),
|
||||
):
|
||||
"""Get price history for a specific TLD."""
|
||||
tld_clean = tld.lower().lstrip(".")
|
||||
|
||||
if tld_clean not in TLD_DATA:
|
||||
raise HTTPException(status_code=404, detail=f"TLD '.{tld_clean}' not found")
|
||||
|
||||
data = TLD_DATA[tld_clean]
|
||||
current_price = get_avg_price(data)
|
||||
|
||||
# Generate realistic historical data
|
||||
history = []
|
||||
current_date = datetime.utcnow()
|
||||
|
||||
# Base price trend calculation
|
||||
trend_factor = 1.0
|
||||
if data["trend"] == "up":
|
||||
trend_factor = 0.92 # Prices were 8% lower
|
||||
elif data["trend"] == "down":
|
||||
trend_factor = 1.05 # Prices were 5% higher
|
||||
|
||||
for i in range(days, -1, -7): # Weekly data points
|
||||
date = current_date - timedelta(days=i)
|
||||
# Interpolate price from past to present
|
||||
progress = 1 - (i / days) # 0 = start, 1 = now
|
||||
if data["trend"] == "up":
|
||||
price = current_price * (trend_factor + (1 - trend_factor) * progress)
|
||||
elif data["trend"] == "down":
|
||||
price = current_price * (trend_factor - (trend_factor - 1) * progress)
|
||||
else:
|
||||
# Stable with small fluctuations
|
||||
import math
|
||||
fluctuation = math.sin(i * 0.1) * 0.02
|
||||
price = current_price * (1 + fluctuation)
|
||||
|
||||
history.append({
|
||||
"date": date.strftime("%Y-%m-%d"),
|
||||
"price": round(price, 2),
|
||||
})
|
||||
|
||||
# Calculate percentage changes
|
||||
price_30d_ago = history[-5]["price"] if len(history) >= 5 else current_price
|
||||
price_90d_ago = history[0]["price"] if len(history) > 0 else current_price
|
||||
|
||||
return {
|
||||
"tld": tld_clean,
|
||||
"type": data["type"],
|
||||
"description": data["description"],
|
||||
"registry": data.get("registry", "Unknown"),
|
||||
"current_price": current_price,
|
||||
"price_change_7d": round((current_price - history[-2]["price"]) / history[-2]["price"] * 100, 2) if len(history) >= 2 else 0,
|
||||
"price_change_30d": round((current_price - price_30d_ago) / price_30d_ago * 100, 2),
|
||||
"price_change_90d": round((current_price - price_90d_ago) / price_90d_ago * 100, 2),
|
||||
"trend": data["trend"],
|
||||
"trend_reason": data["trend_reason"],
|
||||
"history": history,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tld}/compare")
|
||||
async def compare_tld_prices(
|
||||
tld: str,
|
||||
db: Database,
|
||||
):
|
||||
"""Compare prices across different registrars for a TLD."""
|
||||
tld_clean = tld.lower().lstrip(".")
|
||||
|
||||
if tld_clean not in TLD_DATA:
|
||||
raise HTTPException(status_code=404, detail=f"TLD '.{tld_clean}' not found")
|
||||
|
||||
data = TLD_DATA[tld_clean]
|
||||
|
||||
registrars = []
|
||||
for name, prices in data["registrars"].items():
|
||||
registrars.append({
|
||||
"name": name,
|
||||
"registration_price": prices["register"],
|
||||
"renewal_price": prices["renew"],
|
||||
"transfer_price": prices["transfer"],
|
||||
})
|
||||
|
||||
# Sort by registration price
|
||||
registrars.sort(key=lambda x: x["registration_price"])
|
||||
|
||||
return {
|
||||
"tld": tld_clean,
|
||||
"type": data["type"],
|
||||
"description": data["description"],
|
||||
"registry": data.get("registry", "Unknown"),
|
||||
"introduced": data.get("introduced"),
|
||||
"registrars": registrars,
|
||||
"cheapest_registrar": registrars[0]["name"],
|
||||
"cheapest_price": registrars[0]["registration_price"],
|
||||
"price_range": {
|
||||
"min": get_min_price(data),
|
||||
"max": get_max_price(data),
|
||||
"avg": get_avg_price(data),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tld}")
|
||||
async def get_tld_details(
|
||||
tld: str,
|
||||
db: Database,
|
||||
):
|
||||
"""Get complete details for a specific TLD."""
|
||||
tld_clean = tld.lower().lstrip(".")
|
||||
|
||||
if tld_clean not in TLD_DATA:
|
||||
raise HTTPException(status_code=404, detail=f"TLD '.{tld_clean}' not found")
|
||||
|
||||
data = TLD_DATA[tld_clean]
|
||||
|
||||
registrars = []
|
||||
for name, prices in data["registrars"].items():
|
||||
registrars.append({
|
||||
"name": name,
|
||||
"registration_price": prices["register"],
|
||||
"renewal_price": prices["renew"],
|
||||
"transfer_price": prices["transfer"],
|
||||
})
|
||||
registrars.sort(key=lambda x: x["registration_price"])
|
||||
|
||||
return {
|
||||
"tld": tld_clean,
|
||||
"type": data["type"],
|
||||
"description": data["description"],
|
||||
"registry": data.get("registry", "Unknown"),
|
||||
"introduced": data.get("introduced"),
|
||||
"trend": data["trend"],
|
||||
"trend_reason": data["trend_reason"],
|
||||
"pricing": {
|
||||
"avg": get_avg_price(data),
|
||||
"min": get_min_price(data),
|
||||
"max": get_max_price(data),
|
||||
},
|
||||
"registrars": registrars,
|
||||
"cheapest_registrar": registrars[0]["name"],
|
||||
}
|
||||
41
backend/app/config.py
Normal file
41
backend/app/config.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Application configuration using pydantic-settings."""
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite+aiosqlite:///./domainwatch.db"
|
||||
|
||||
# JWT Settings
|
||||
secret_key: str = "dev-secret-key-change-in-production"
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 1440 # 24 hours
|
||||
|
||||
# App Settings
|
||||
app_name: str = "DomainWatch"
|
||||
debug: bool = True
|
||||
|
||||
# Email Settings (optional)
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
email_from: str = ""
|
||||
|
||||
# Scheduler Settings
|
||||
check_hour: int = 6
|
||||
check_minute: int = 0
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
|
||||
48
backend/app/database.py
Normal file
48
backend/app/database.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Database configuration and session management."""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create async engine
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency to get database session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
84
backend/app/main.py
Normal file
84
backend/app/main.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""FastAPI application entry point."""
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import api_router
|
||||
from app.config import get_settings
|
||||
from app.database import init_db
|
||||
from app.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan handler."""
|
||||
# Startup
|
||||
logger.info(f"Starting {settings.app_name}...")
|
||||
|
||||
# Initialize database
|
||||
await init_db()
|
||||
logger.info("Database initialized")
|
||||
|
||||
# Start scheduler
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
stop_scheduler()
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
description="Domain availability monitoring service",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://10.42.0.73:3000",
|
||||
# Add production origins here
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API routes
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
7
backend/app/models/__init__.py
Normal file
7
backend/app/models/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Database models."""
|
||||
from app.models.user import User
|
||||
from app.models.domain import Domain, DomainCheck
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.tld_price import TLDPrice, TLDInfo
|
||||
|
||||
__all__ = ["User", "Domain", "DomainCheck", "Subscription", "TLDPrice", "TLDInfo"]
|
||||
80
backend/app/models/domain.py
Normal file
80
backend/app/models/domain.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""Domain models."""
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Text, Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DomainStatus(str, Enum):
|
||||
"""Domain availability status."""
|
||||
AVAILABLE = "available"
|
||||
TAKEN = "taken"
|
||||
ERROR = "error"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class Domain(Base):
|
||||
"""Domain model for tracking domain names."""
|
||||
|
||||
__tablename__ = "domains"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), index=True, nullable=False)
|
||||
|
||||
# Current status
|
||||
status: Mapped[DomainStatus] = mapped_column(
|
||||
SQLEnum(DomainStatus), default=DomainStatus.UNKNOWN
|
||||
)
|
||||
is_available: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# WHOIS data (optional)
|
||||
registrar: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
expiration_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# User relationship
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
user: Mapped["User"] = relationship("User", back_populates="domains")
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Check history relationship
|
||||
checks: Mapped[list["DomainCheck"]] = relationship(
|
||||
"DomainCheck", back_populates="domain", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Notification settings
|
||||
notify_on_available: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Domain {self.name} ({self.status})>"
|
||||
|
||||
|
||||
class DomainCheck(Base):
|
||||
"""History of domain availability checks."""
|
||||
|
||||
__tablename__ = "domain_checks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
domain_id: Mapped[int] = mapped_column(ForeignKey("domains.id"), nullable=False)
|
||||
|
||||
# Check results
|
||||
status: Mapped[DomainStatus] = mapped_column(SQLEnum(DomainStatus))
|
||||
is_available: Mapped[bool] = mapped_column(Boolean)
|
||||
|
||||
# Details
|
||||
response_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Timestamp
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationship
|
||||
domain: Mapped["Domain"] = relationship("Domain", back_populates="checks")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DomainCheck {self.domain_id} at {self.checked_at}>"
|
||||
|
||||
139
backend/app/models/subscription.py
Normal file
139
backend/app/models/subscription.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Subscription model."""
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, Boolean, Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SubscriptionTier(str, Enum):
|
||||
"""Subscription tiers matching frontend pricing."""
|
||||
STARTER = "starter" # Free
|
||||
PROFESSIONAL = "professional" # $4.99/mo
|
||||
ENTERPRISE = "enterprise" # $9.99/mo
|
||||
|
||||
|
||||
class SubscriptionStatus(str, Enum):
|
||||
"""Subscription status."""
|
||||
ACTIVE = "active"
|
||||
CANCELLED = "cancelled"
|
||||
EXPIRED = "expired"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
# Plan configuration
|
||||
TIER_CONFIG = {
|
||||
SubscriptionTier.STARTER: {
|
||||
"name": "Starter",
|
||||
"price": 0,
|
||||
"domain_limit": 3,
|
||||
"check_frequency": "daily", # daily, hourly
|
||||
"history_days": 0, # No history
|
||||
"features": {
|
||||
"email_alerts": True,
|
||||
"priority_alerts": False,
|
||||
"full_whois": False,
|
||||
"expiration_tracking": False,
|
||||
"api_access": False,
|
||||
"webhooks": False,
|
||||
}
|
||||
},
|
||||
SubscriptionTier.PROFESSIONAL: {
|
||||
"name": "Professional",
|
||||
"price": 4.99,
|
||||
"domain_limit": 25,
|
||||
"check_frequency": "daily",
|
||||
"history_days": 30,
|
||||
"features": {
|
||||
"email_alerts": True,
|
||||
"priority_alerts": True,
|
||||
"full_whois": True,
|
||||
"expiration_tracking": True,
|
||||
"api_access": False,
|
||||
"webhooks": False,
|
||||
}
|
||||
},
|
||||
SubscriptionTier.ENTERPRISE: {
|
||||
"name": "Enterprise",
|
||||
"price": 9.99,
|
||||
"domain_limit": 100,
|
||||
"check_frequency": "hourly",
|
||||
"history_days": -1, # Unlimited
|
||||
"features": {
|
||||
"email_alerts": True,
|
||||
"priority_alerts": True,
|
||||
"full_whois": True,
|
||||
"expiration_tracking": True,
|
||||
"api_access": True,
|
||||
"webhooks": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""Subscription model for tracking user plans."""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True, nullable=False)
|
||||
|
||||
# Plan details
|
||||
tier: Mapped[SubscriptionTier] = mapped_column(
|
||||
SQLEnum(SubscriptionTier), default=SubscriptionTier.STARTER
|
||||
)
|
||||
status: Mapped[SubscriptionStatus] = mapped_column(
|
||||
SQLEnum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
|
||||
)
|
||||
|
||||
# Limits
|
||||
domain_limit: Mapped[int] = mapped_column(Integer, default=3)
|
||||
|
||||
# Payment info (for future integration)
|
||||
payment_reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Dates
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship("User", back_populates="subscription")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""Check if subscription is currently active."""
|
||||
if self.status != SubscriptionStatus.ACTIVE:
|
||||
return False
|
||||
if self.expires_at and self.expires_at < datetime.utcnow():
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def config(self) -> dict:
|
||||
"""Get configuration for this subscription tier."""
|
||||
return TIER_CONFIG.get(self.tier, TIER_CONFIG[SubscriptionTier.STARTER])
|
||||
|
||||
@property
|
||||
def max_domains(self) -> int:
|
||||
"""Get maximum allowed domains for this subscription."""
|
||||
return self.config["domain_limit"]
|
||||
|
||||
@property
|
||||
def check_frequency(self) -> str:
|
||||
"""Get check frequency for this subscription."""
|
||||
return self.config["check_frequency"]
|
||||
|
||||
@property
|
||||
def history_days(self) -> int:
|
||||
"""Get history retention days. -1 = unlimited."""
|
||||
return self.config["history_days"]
|
||||
|
||||
def has_feature(self, feature: str) -> bool:
|
||||
"""Check if subscription has a specific feature."""
|
||||
return self.config.get("features", {}).get(feature, False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Subscription {self.tier.value} for user {self.user_id}>"
|
||||
74
backend/app/models/tld_price.py
Normal file
74
backend/app/models/tld_price.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""TLD Price History model for tracking domain extension prices over time."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Float, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TLDPrice(Base):
|
||||
"""
|
||||
TLD Price model for tracking domain extension prices.
|
||||
|
||||
Stores historical pricing data for different TLDs (e.g., .com, .io, .co)
|
||||
from various registrars.
|
||||
"""
|
||||
|
||||
__tablename__ = "tld_prices"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# TLD info
|
||||
tld: Mapped[str] = mapped_column(String(50), index=True, nullable=False) # e.g., "com", "io", "co"
|
||||
|
||||
# Registrar info
|
||||
registrar: Mapped[str] = mapped_column(String(100), nullable=False) # e.g., "namecheap", "godaddy"
|
||||
|
||||
# Pricing (in USD)
|
||||
registration_price: Mapped[float] = mapped_column(Float, nullable=False) # First year
|
||||
renewal_price: Mapped[float] = mapped_column(Float, nullable=True) # Renewal
|
||||
transfer_price: Mapped[float] = mapped_column(Float, nullable=True) # Transfer
|
||||
|
||||
# Currency
|
||||
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||
|
||||
# Timestamp
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Additional info
|
||||
promo_price: Mapped[float | None] = mapped_column(Float, nullable=True) # Promotional price
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TLDPrice .{self.tld} @ {self.registrar}: ${self.registration_price}>"
|
||||
|
||||
|
||||
class TLDInfo(Base):
|
||||
"""
|
||||
General TLD information and metadata.
|
||||
"""
|
||||
|
||||
__tablename__ = "tld_info"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
tld: Mapped[str] = mapped_column(String(50), unique=True, index=True, nullable=False)
|
||||
|
||||
# Metadata
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=True) # generic, country-code, sponsored
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
registry: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
# Restrictions
|
||||
is_restricted: Mapped[bool] = mapped_column(default=False)
|
||||
restriction_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Popularity (for sorting)
|
||||
popularity_rank: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TLDInfo .{self.tld}>"
|
||||
|
||||
41
backend/app/models/user.py
Normal file
41
backend/app/models/user.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""User model."""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Boolean, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and domain tracking."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Profile
|
||||
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
# Relationships
|
||||
domains: Mapped[list["Domain"]] = relationship(
|
||||
"Domain", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
subscription: Mapped["Subscription"] = relationship(
|
||||
"Subscription", back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
|
||||
124
backend/app/scheduler.py
Normal file
124
backend/app/scheduler.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Background scheduler for daily domain checks."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.domain import Domain, DomainCheck
|
||||
from app.services.domain_checker import domain_checker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def check_all_domains():
|
||||
"""Check availability of all monitored domains."""
|
||||
logger.info("Starting daily domain check...")
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Get all domains
|
||||
result = await db.execute(select(Domain))
|
||||
domains = result.scalars().all()
|
||||
|
||||
logger.info(f"Checking {len(domains)} domains...")
|
||||
|
||||
checked = 0
|
||||
errors = 0
|
||||
newly_available = []
|
||||
|
||||
for domain in domains:
|
||||
try:
|
||||
# Check domain availability
|
||||
check_result = await domain_checker.check_domain(domain.name)
|
||||
|
||||
# Track if domain became available
|
||||
was_taken = not domain.is_available
|
||||
is_now_available = check_result.is_available
|
||||
|
||||
if was_taken and is_now_available and domain.notify_on_available:
|
||||
newly_available.append(domain)
|
||||
|
||||
# Update domain
|
||||
domain.status = check_result.status
|
||||
domain.is_available = check_result.is_available
|
||||
domain.registrar = check_result.registrar
|
||||
domain.expiration_date = check_result.expiration_date
|
||||
domain.last_checked = datetime.utcnow()
|
||||
|
||||
# Create check record
|
||||
check = DomainCheck(
|
||||
domain_id=domain.id,
|
||||
status=check_result.status,
|
||||
is_available=check_result.is_available,
|
||||
response_data=str(check_result.to_dict()),
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(check)
|
||||
|
||||
checked += 1
|
||||
|
||||
# Small delay to avoid rate limiting
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking domain {domain.name}: {e}")
|
||||
errors += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
elapsed = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
f"Domain check complete. Checked: {checked}, Errors: {errors}, "
|
||||
f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s"
|
||||
)
|
||||
|
||||
# TODO: Send notifications for newly available domains
|
||||
if newly_available:
|
||||
logger.info(f"Domains that became available: {[d.name for d in newly_available]}")
|
||||
# await send_availability_notifications(newly_available)
|
||||
|
||||
|
||||
def setup_scheduler():
|
||||
"""Configure and start the scheduler."""
|
||||
# Daily check at configured hour
|
||||
scheduler.add_job(
|
||||
check_all_domains,
|
||||
CronTrigger(hour=settings.check_hour, minute=settings.check_minute),
|
||||
id="daily_domain_check",
|
||||
name="Daily Domain Availability Check",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Scheduler configured. Daily check at {settings.check_hour:02d}:{settings.check_minute:02d}"
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the scheduler if not already running."""
|
||||
if not scheduler.running:
|
||||
setup_scheduler()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the scheduler."""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
|
||||
async def run_manual_check():
|
||||
"""Run domain check manually (for testing or on-demand)."""
|
||||
await check_all_domains()
|
||||
|
||||
35
backend/app/schemas/__init__.py
Normal file
35
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Pydantic schemas for API."""
|
||||
from app.schemas.auth import (
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
Token,
|
||||
TokenData,
|
||||
)
|
||||
from app.schemas.domain import (
|
||||
DomainCreate,
|
||||
DomainResponse,
|
||||
DomainCheckRequest,
|
||||
DomainCheckResponse,
|
||||
DomainListResponse,
|
||||
)
|
||||
from app.schemas.subscription import (
|
||||
SubscriptionResponse,
|
||||
SubscriptionUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserCreate",
|
||||
"UserLogin",
|
||||
"UserResponse",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"DomainCreate",
|
||||
"DomainResponse",
|
||||
"DomainCheckRequest",
|
||||
"DomainCheckResponse",
|
||||
"DomainListResponse",
|
||||
"SubscriptionResponse",
|
||||
"SubscriptionUpdate",
|
||||
]
|
||||
|
||||
45
backend/app/schemas/auth.py
Normal file
45
backend/app/schemas/auth.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Authentication schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for user registration."""
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=100)
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""Schema for user login."""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Schema for user response."""
|
||||
id: int
|
||||
email: str
|
||||
name: Optional[str]
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for JWT token response."""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Schema for token payload data."""
|
||||
user_id: Optional[int] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
90
backend/app/schemas/domain.py
Normal file
90
backend/app/schemas/domain.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Domain schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.models.domain import DomainStatus
|
||||
|
||||
|
||||
class DomainCreate(BaseModel):
|
||||
"""Schema for adding a domain to monitoring list."""
|
||||
name: str = Field(..., min_length=4, max_length=255)
|
||||
notify_on_available: bool = True
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_domain_name(cls, v: str) -> str:
|
||||
"""Validate and normalize domain name."""
|
||||
v = v.lower().strip()
|
||||
if v.startswith('http://'):
|
||||
v = v[7:]
|
||||
elif v.startswith('https://'):
|
||||
v = v[8:]
|
||||
if v.startswith('www.'):
|
||||
v = v[4:]
|
||||
v = v.split('/')[0]
|
||||
|
||||
if '.' not in v:
|
||||
raise ValueError('Domain must include TLD (e.g., .com)')
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class DomainResponse(BaseModel):
|
||||
"""Schema for domain response."""
|
||||
id: int
|
||||
name: str
|
||||
status: DomainStatus
|
||||
is_available: bool
|
||||
registrar: Optional[str]
|
||||
expiration_date: Optional[datetime]
|
||||
notify_on_available: bool
|
||||
created_at: datetime
|
||||
last_checked: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DomainCheckRequest(BaseModel):
|
||||
"""Schema for quick domain availability check."""
|
||||
domain: str = Field(..., min_length=4, max_length=255)
|
||||
quick: bool = False # If True, only DNS check (faster)
|
||||
|
||||
@field_validator('domain')
|
||||
@classmethod
|
||||
def validate_domain(cls, v: str) -> str:
|
||||
"""Validate and normalize domain."""
|
||||
v = v.lower().strip()
|
||||
if v.startswith('http://'):
|
||||
v = v[7:]
|
||||
elif v.startswith('https://'):
|
||||
v = v[8:]
|
||||
if v.startswith('www.'):
|
||||
v = v[4:]
|
||||
v = v.split('/')[0]
|
||||
return v
|
||||
|
||||
|
||||
class DomainCheckResponse(BaseModel):
|
||||
"""Schema for domain check response."""
|
||||
domain: str
|
||||
status: str
|
||||
is_available: bool
|
||||
registrar: Optional[str] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
creation_date: Optional[datetime] = None
|
||||
name_servers: Optional[List[str]] = None
|
||||
error_message: Optional[str] = None
|
||||
checked_at: datetime
|
||||
|
||||
|
||||
class DomainListResponse(BaseModel):
|
||||
"""Schema for paginated domain list."""
|
||||
domains: List[DomainResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
pages: int
|
||||
|
||||
41
backend/app/schemas/subscription.py
Normal file
41
backend/app/schemas/subscription.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Subscription schemas."""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SubscriptionResponse(BaseModel):
|
||||
"""Schema for subscription response."""
|
||||
id: int
|
||||
tier: str
|
||||
tier_name: str
|
||||
status: str
|
||||
domain_limit: int
|
||||
domains_used: int = 0
|
||||
check_frequency: str
|
||||
history_days: int
|
||||
features: Dict[str, bool]
|
||||
started_at: datetime
|
||||
expires_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SubscriptionTierInfo(BaseModel):
|
||||
"""Schema for subscription tier information."""
|
||||
id: str
|
||||
name: str
|
||||
domain_limit: int
|
||||
price: float
|
||||
check_frequency: str
|
||||
features: List[str]
|
||||
feature_flags: Dict[str, bool]
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
"""Schema for updating subscription (admin/payment webhook)."""
|
||||
tier: str
|
||||
expires_at: Optional[datetime] = None
|
||||
payment_reference: Optional[str] = None
|
||||
6
backend/app/services/__init__.py
Normal file
6
backend/app/services/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Services."""
|
||||
from app.services.domain_checker import DomainChecker
|
||||
from app.services.auth import AuthService
|
||||
|
||||
__all__ = ["DomainChecker", "AuthService"]
|
||||
|
||||
118
backend/app/services/auth.py
Normal file
118
backend/app/services/auth.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Authentication service."""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
salt = bcrypt.gensalt(rounds=12)
|
||||
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
@staticmethod
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
|
||||
"""Get user by email."""
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
"""Get user by ID."""
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
|
||||
"""Authenticate user with email and password."""
|
||||
user = await AuthService.get_user_by_email(db, email)
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not AuthService.verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def create_user(
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
password: str,
|
||||
name: Optional[str] = None
|
||||
) -> User:
|
||||
"""Create a new user with default subscription."""
|
||||
# Create user
|
||||
user = User(
|
||||
email=email,
|
||||
hashed_password=AuthService.hash_password(password),
|
||||
name=name,
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Create default starter subscription
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
tier=SubscriptionTier.STARTER,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
domain_limit=TIER_CONFIG[SubscriptionTier.STARTER]["domain_limit"],
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# Singleton instance
|
||||
auth_service = AuthService()
|
||||
|
||||
491
backend/app/services/domain_checker.py
Normal file
491
backend/app/services/domain_checker.py
Normal file
@ -0,0 +1,491 @@
|
||||
"""
|
||||
Advanced Domain Availability Checker
|
||||
|
||||
Uses multiple methods for maximum accuracy:
|
||||
1. RDAP (Registration Data Access Protocol) - Modern, accurate, JSON format
|
||||
2. DNS lookup - Fast availability check
|
||||
3. WHOIS - Fallback for TLDs without RDAP
|
||||
|
||||
Performance optimized with caching and async operations.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
import dns.resolver
|
||||
import whois
|
||||
import whodap
|
||||
|
||||
from app.models.domain import DomainStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DomainCheckResult:
|
||||
"""Result of a domain availability check."""
|
||||
domain: str
|
||||
status: DomainStatus
|
||||
is_available: bool
|
||||
registrar: Optional[str] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
creation_date: Optional[datetime] = None
|
||||
updated_date: Optional[datetime] = None
|
||||
name_servers: Optional[list[str]] = None
|
||||
error_message: Optional[str] = None
|
||||
check_method: str = "unknown" # rdap, whois, dns
|
||||
raw_data: Optional[dict] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"domain": self.domain,
|
||||
"status": self.status.value,
|
||||
"is_available": self.is_available,
|
||||
"registrar": self.registrar,
|
||||
"expiration_date": self.expiration_date.isoformat() if self.expiration_date else None,
|
||||
"creation_date": self.creation_date.isoformat() if self.creation_date else None,
|
||||
"updated_date": self.updated_date.isoformat() if self.updated_date else None,
|
||||
"name_servers": self.name_servers,
|
||||
"error_message": self.error_message,
|
||||
"check_method": self.check_method,
|
||||
}
|
||||
|
||||
|
||||
class DomainChecker:
|
||||
"""
|
||||
Advanced domain availability checker.
|
||||
|
||||
Priority: RDAP > DNS > WHOIS
|
||||
"""
|
||||
|
||||
# TLDs known to support RDAP
|
||||
RDAP_SUPPORTED_TLDS = {
|
||||
'com', 'net', 'org', 'info', 'biz', 'mobi', 'name', 'pro',
|
||||
'app', 'dev', 'page', 'new', 'day', 'eat', 'fly', 'how',
|
||||
'io', 'co', 'ai', 'me', 'tv', 'cc', 'ws',
|
||||
'xyz', 'top', 'site', 'online', 'tech', 'store', 'club',
|
||||
'de', 'uk', 'fr', 'nl', 'eu', 'be', 'at', 'us',
|
||||
}
|
||||
|
||||
# TLDs that only support WHOIS (no RDAP)
|
||||
WHOIS_ONLY_TLDS = {
|
||||
'ch', 'li', 'ru', 'su', 'ua', 'by', 'kz',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the domain checker."""
|
||||
self._dns_resolver = dns.resolver.Resolver()
|
||||
self._dns_resolver.timeout = 3
|
||||
self._dns_resolver.lifetime = 5
|
||||
self._cache = {}
|
||||
self._cache_ttl = 300 # 5 minutes
|
||||
|
||||
def _normalize_domain(self, domain: str) -> str:
|
||||
"""Normalize domain name."""
|
||||
domain = domain.lower().strip()
|
||||
if domain.startswith('http://'):
|
||||
domain = domain[7:]
|
||||
elif domain.startswith('https://'):
|
||||
domain = domain[8:]
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
domain = domain.split('/')[0]
|
||||
return domain
|
||||
|
||||
def _get_tld(self, domain: str) -> str:
|
||||
"""Extract TLD from domain."""
|
||||
parts = domain.split('.')
|
||||
return parts[-1].lower() if parts else ''
|
||||
|
||||
def _get_sld(self, domain: str) -> str:
|
||||
"""Extract second-level domain (without TLD)."""
|
||||
parts = domain.split('.')
|
||||
return parts[0] if parts else domain
|
||||
|
||||
def _parse_datetime(self, date_str: str) -> Optional[datetime]:
|
||||
"""Parse various datetime formats."""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
# Common formats
|
||||
formats = [
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||
"%Y-%m-%dT%H:%M:%S%z",
|
||||
"%Y-%m-%dT%H:%M:%S.%f%z",
|
||||
"%Y-%m-%d",
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(date_str.replace('+00:00', 'Z').replace('.000Z', 'Z'), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try ISO format
|
||||
try:
|
||||
# Handle formats like "2028-09-14T07:00:00.000+00:00"
|
||||
if '+' in date_str:
|
||||
date_str = date_str.split('+')[0] + '+00:00'
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def _check_rdap(self, domain: str) -> Optional[DomainCheckResult]:
|
||||
"""
|
||||
Check domain using RDAP (Registration Data Access Protocol).
|
||||
|
||||
Returns None if RDAP is not available for this TLD.
|
||||
"""
|
||||
tld = self._get_tld(domain)
|
||||
sld = self._get_sld(domain)
|
||||
|
||||
try:
|
||||
# Run RDAP lookup in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: whodap.lookup_domain(sld, tld)
|
||||
)
|
||||
|
||||
# Parse events for dates
|
||||
expiration_date = None
|
||||
creation_date = None
|
||||
updated_date = None
|
||||
registrar = None
|
||||
|
||||
if response.events:
|
||||
for event in response.events:
|
||||
# Access event data from __dict__
|
||||
event_dict = event.__dict__ if hasattr(event, '__dict__') else {}
|
||||
action = event_dict.get('eventAction', '')
|
||||
date_str = event_dict.get('eventDate', '')
|
||||
|
||||
if not action or not date_str:
|
||||
continue
|
||||
|
||||
action_lower = action.lower()
|
||||
if 'expiration' in action_lower and not expiration_date:
|
||||
expiration_date = self._parse_datetime(date_str)
|
||||
elif 'registration' in action_lower and not creation_date:
|
||||
creation_date = self._parse_datetime(date_str)
|
||||
elif 'changed' in action_lower and 'database' not in action_lower:
|
||||
updated_date = self._parse_datetime(date_str)
|
||||
|
||||
# Extract registrar from entities
|
||||
if response.entities:
|
||||
for entity in response.entities:
|
||||
try:
|
||||
entity_dict = entity.__dict__ if hasattr(entity, '__dict__') else {}
|
||||
roles = entity_dict.get('roles', [])
|
||||
if 'registrar' in roles:
|
||||
vcard = entity_dict.get('vcardArray', [])
|
||||
if isinstance(vcard, list) and len(vcard) > 1:
|
||||
for item in vcard[1]:
|
||||
if isinstance(item, list) and len(item) > 3:
|
||||
if item[0] == 'fn' and item[3]:
|
||||
registrar = str(item[3])
|
||||
break
|
||||
elif item[0] == 'org' and item[3]:
|
||||
registrar = str(item[3])
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.TAKEN,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration_date,
|
||||
creation_date=creation_date,
|
||||
updated_date=updated_date,
|
||||
check_method="rdap",
|
||||
)
|
||||
|
||||
except NotImplementedError:
|
||||
# No RDAP server for this TLD
|
||||
logger.debug(f"No RDAP server for TLD .{tld}")
|
||||
return None
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Check if domain is not found (available)
|
||||
if 'not found' in error_msg or '404' in error_msg:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="rdap",
|
||||
)
|
||||
logger.warning(f"RDAP check failed for {domain}: {e}")
|
||||
return None
|
||||
|
||||
async def _check_whois(self, domain: str) -> DomainCheckResult:
|
||||
"""Check domain using WHOIS (fallback method)."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
w = await loop.run_in_executor(None, whois.whois, domain)
|
||||
|
||||
# Check if domain is available
|
||||
# 1. No domain_name returned
|
||||
if w.domain_name is None:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="whois",
|
||||
)
|
||||
|
||||
# 2. Check the raw text for "not found" indicators
|
||||
raw_text = str(w.text).lower() if hasattr(w, 'text') and w.text else ""
|
||||
not_found_indicators = [
|
||||
'no match',
|
||||
'not found',
|
||||
'no entries',
|
||||
'no data',
|
||||
'status: free',
|
||||
'no entry',
|
||||
'we do not have an entry',
|
||||
'domain not found',
|
||||
'is available',
|
||||
'available for registration',
|
||||
'no object found',
|
||||
'object does not exist',
|
||||
]
|
||||
if any(indicator in raw_text for indicator in not_found_indicators):
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="whois",
|
||||
)
|
||||
|
||||
# 3. Check if no registrar and no creation date (likely available)
|
||||
if not w.registrar and not w.creation_date and not w.name_servers:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="whois",
|
||||
)
|
||||
|
||||
# Extract data
|
||||
expiration = None
|
||||
creation = None
|
||||
registrar = None
|
||||
name_servers = None
|
||||
|
||||
if w.expiration_date:
|
||||
if isinstance(w.expiration_date, list):
|
||||
expiration = w.expiration_date[0]
|
||||
else:
|
||||
expiration = w.expiration_date
|
||||
|
||||
if w.creation_date:
|
||||
if isinstance(w.creation_date, list):
|
||||
creation = w.creation_date[0]
|
||||
else:
|
||||
creation = w.creation_date
|
||||
|
||||
if w.registrar:
|
||||
registrar = w.registrar if isinstance(w.registrar, str) else str(w.registrar)
|
||||
|
||||
if w.name_servers:
|
||||
if isinstance(w.name_servers, list):
|
||||
name_servers = [str(ns).lower() for ns in w.name_servers]
|
||||
else:
|
||||
name_servers = [str(w.name_servers).lower()]
|
||||
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.TAKEN,
|
||||
is_available=False,
|
||||
registrar=registrar,
|
||||
expiration_date=expiration,
|
||||
creation_date=creation,
|
||||
name_servers=name_servers,
|
||||
check_method="whois",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if it's a "domain not found" type error (indicates available)
|
||||
error_str = str(e).lower()
|
||||
not_found_phrases = [
|
||||
'no match',
|
||||
'not found',
|
||||
'no entries',
|
||||
'no data',
|
||||
'status: free',
|
||||
'no entry',
|
||||
'we do not have an entry',
|
||||
'domain not found',
|
||||
'is available',
|
||||
'no object found',
|
||||
'object does not exist',
|
||||
]
|
||||
if any(phrase in error_str for phrase in not_found_phrases):
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE,
|
||||
is_available=True,
|
||||
check_method="whois",
|
||||
)
|
||||
# Otherwise it's a real error
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.ERROR,
|
||||
is_available=False,
|
||||
error_message=str(e),
|
||||
check_method="whois",
|
||||
)
|
||||
|
||||
async def _check_dns(self, domain: str) -> bool:
|
||||
"""
|
||||
Quick DNS check for domain existence.
|
||||
|
||||
Returns True if domain appears available (no DNS records).
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Try A record
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._dns_resolver.resolve(domain, 'A')
|
||||
)
|
||||
return False # Has A record = taken
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
|
||||
pass
|
||||
|
||||
# Try NS record
|
||||
try:
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self._dns_resolver.resolve(domain, 'NS')
|
||||
)
|
||||
return False # Has NS record = taken
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers):
|
||||
pass
|
||||
|
||||
return True # No DNS records = likely available
|
||||
|
||||
except Exception:
|
||||
return True # On error, assume might be available
|
||||
|
||||
async def check_domain(self, domain: str, quick: bool = False) -> DomainCheckResult:
|
||||
"""
|
||||
Check domain availability using the best available method.
|
||||
|
||||
Priority:
|
||||
1. RDAP (most accurate, modern protocol)
|
||||
2. WHOIS (fallback for TLDs without RDAP)
|
||||
3. DNS (quick check only)
|
||||
|
||||
Args:
|
||||
domain: Domain name to check
|
||||
quick: If True, only use DNS (faster but less accurate)
|
||||
|
||||
Returns:
|
||||
DomainCheckResult with detailed availability info
|
||||
"""
|
||||
domain = self._normalize_domain(domain)
|
||||
|
||||
if not domain or '.' not in domain:
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.ERROR,
|
||||
is_available=False,
|
||||
error_message="Invalid domain format",
|
||||
)
|
||||
|
||||
tld = self._get_tld(domain)
|
||||
|
||||
# Quick DNS check
|
||||
if quick:
|
||||
dns_available = await self._check_dns(domain)
|
||||
return DomainCheckResult(
|
||||
domain=domain,
|
||||
status=DomainStatus.AVAILABLE if dns_available else DomainStatus.TAKEN,
|
||||
is_available=dns_available,
|
||||
check_method="dns",
|
||||
)
|
||||
|
||||
# Try RDAP first (best accuracy)
|
||||
if tld not in self.WHOIS_ONLY_TLDS:
|
||||
rdap_result = await self._check_rdap(domain)
|
||||
if rdap_result:
|
||||
# Validate with DNS if RDAP says available
|
||||
if rdap_result.is_available:
|
||||
dns_available = await self._check_dns(domain)
|
||||
if not dns_available:
|
||||
rdap_result.status = DomainStatus.TAKEN
|
||||
rdap_result.is_available = False
|
||||
return rdap_result
|
||||
|
||||
# Fall back to WHOIS
|
||||
whois_result = await self._check_whois(domain)
|
||||
|
||||
# Validate with DNS
|
||||
if whois_result.is_available:
|
||||
dns_available = await self._check_dns(domain)
|
||||
if not dns_available:
|
||||
whois_result.status = DomainStatus.TAKEN
|
||||
whois_result.is_available = False
|
||||
|
||||
return whois_result
|
||||
|
||||
async def check_multiple(self, domains: list[str], quick: bool = False) -> list[DomainCheckResult]:
|
||||
"""
|
||||
Check multiple domains concurrently.
|
||||
|
||||
Args:
|
||||
domains: List of domain names
|
||||
quick: Use quick DNS-only check
|
||||
|
||||
Returns:
|
||||
List of DomainCheckResult
|
||||
"""
|
||||
tasks = [self.check_domain(d, quick=quick) for d in domains]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
def validate_domain(self, domain: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate domain format.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
domain = self._normalize_domain(domain)
|
||||
|
||||
if not domain:
|
||||
return False, "Domain cannot be empty"
|
||||
|
||||
if '.' not in domain:
|
||||
return False, "Domain must include TLD (e.g., .com)"
|
||||
|
||||
parts = domain.split('.')
|
||||
|
||||
for part in parts:
|
||||
if not part:
|
||||
return False, "Invalid domain format"
|
||||
if len(part) > 63:
|
||||
return False, "Domain label too long (max 63 characters)"
|
||||
if not all(c.isalnum() or c == '-' for c in part):
|
||||
return False, "Domain contains invalid characters"
|
||||
if part.startswith('-') or part.endswith('-'):
|
||||
return False, "Domain labels cannot start or end with hyphen"
|
||||
|
||||
if len(domain) > 253:
|
||||
return False, "Domain name too long (max 253 characters)"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
# Singleton instance
|
||||
domain_checker = DomainChecker()
|
||||
33
backend/env.example
Normal file
33
backend/env.example
Normal file
@ -0,0 +1,33 @@
|
||||
# =================================
|
||||
# pounce Backend Configuration
|
||||
# =================================
|
||||
# Copy this file to .env and update values
|
||||
|
||||
# Database
|
||||
# SQLite (Development)
|
||||
DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db
|
||||
|
||||
# PostgreSQL (Production)
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pounce
|
||||
|
||||
# Security
|
||||
# IMPORTANT: Generate a secure random key for production!
|
||||
# Use: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production-min-32-characters
|
||||
|
||||
# JWT Settings
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||
|
||||
# CORS Origins (comma-separated)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Email Notifications (Optional)
|
||||
# SMTP_HOST=smtp.gmail.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-email@gmail.com
|
||||
# SMTP_PASSWORD=your-app-password
|
||||
# SMTP_FROM=noreply@yourdomain.com
|
||||
|
||||
# Scheduler Settings
|
||||
SCHEDULER_CHECK_INTERVAL_HOURS=24
|
||||
|
||||
25
backend/env.example.txt
Normal file
25
backend/env.example.txt
Normal file
@ -0,0 +1,25 @@
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./domainwatch.db
|
||||
# For PostgreSQL in production:
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/domainwatch
|
||||
|
||||
# JWT Settings
|
||||
SECRET_KEY=your-super-secret-key-change-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
|
||||
# App Settings
|
||||
APP_NAME=DomainWatch
|
||||
DEBUG=True
|
||||
|
||||
# Email Settings (optional)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASSWORD=your-password
|
||||
EMAIL_FROM=noreply@domainwatch.com
|
||||
|
||||
# Scheduler Settings
|
||||
CHECK_HOUR=6
|
||||
CHECK_MINUTE=0
|
||||
|
||||
35
backend/requirements.txt
Normal file
35
backend/requirements.txt
Normal file
@ -0,0 +1,35 @@
|
||||
# FastAPI & Server
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
python-multipart>=0.0.12
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.35
|
||||
alembic>=1.14.0
|
||||
aiosqlite>=0.20.0
|
||||
|
||||
# Authentication
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
bcrypt>=4.0.0,<4.1
|
||||
|
||||
# Domain Checking
|
||||
python-whois>=0.9.4
|
||||
dnspython>=2.7.0
|
||||
whodap>=0.1.12
|
||||
|
||||
# Scheduling
|
||||
apscheduler>=3.10.4
|
||||
|
||||
# Email (optional, for notifications)
|
||||
aiosmtplib>=3.0.2
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.1
|
||||
pydantic[email]>=2.10.0
|
||||
pydantic-settings>=2.6.0
|
||||
httpx>=0.28.0
|
||||
|
||||
# Production Database (optional)
|
||||
asyncpg>=0.30.0
|
||||
|
||||
12
backend/run.py
Normal file
12
backend/run.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Development server runner."""
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@ -0,0 +1,59 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: pounce-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: pounce
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||
POSTGRES_DB: pounce
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pounce"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pounce-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://pounce:${DB_PASSWORD:-changeme}@db:5432/pounce
|
||||
SECRET_KEY: ${SECRET_KEY:-change-this-in-production}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Next.js Frontend
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pounce-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
NEXT_PUBLIC_API_URL: ${API_URL:-http://localhost:8000}
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
47
frontend/Dockerfile
Normal file
47
frontend/Dockerfile
Normal file
@ -0,0 +1,47 @@
|
||||
# pounce Frontend Dockerfile
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Rebuild source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
11
frontend/env.example
Normal file
11
frontend/env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# =================================
|
||||
# pounce Frontend Configuration
|
||||
# =================================
|
||||
# Copy this file to .env.local and update values
|
||||
|
||||
# Backend API URL
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
|
||||
# Production example:
|
||||
# NEXT_PUBLIC_API_URL=https://api.yourdomain.com
|
||||
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
8
frontend/next.config.js
Normal file
8
frontend/next.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone', // Required for Docker deployment
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
5719
frontend/package-lock.json
generated
Normal file
5719
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "domainwatch-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"zustand": "^4.4.7",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"typescript": "^5.3.3",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.32",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
565
frontend/src/app/dashboard/page.tsx
Normal file
565
frontend/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Search,
|
||||
Calendar,
|
||||
History,
|
||||
ChevronRight,
|
||||
Bell,
|
||||
Check,
|
||||
X,
|
||||
Zap,
|
||||
Crown,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface DomainHistory {
|
||||
id: number
|
||||
status: string
|
||||
is_available: boolean
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
checkAuth,
|
||||
domains,
|
||||
subscription,
|
||||
addDomain,
|
||||
deleteDomain,
|
||||
refreshDomain,
|
||||
} = useStore()
|
||||
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
|
||||
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.push('/login')
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router])
|
||||
|
||||
const handleAddDomain = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setAdding(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await addDomain(newDomain)
|
||||
setNewDomain('')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add domain')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async (id: number) => {
|
||||
setRefreshingId(id)
|
||||
try {
|
||||
await refreshDomain(id)
|
||||
} finally {
|
||||
setRefreshingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Remove this domain from your watchlist?')) return
|
||||
await deleteDomain(id)
|
||||
}
|
||||
|
||||
const loadDomainHistory = async (domainId: number) => {
|
||||
if (!subscription?.features?.expiration_tracking) {
|
||||
setError('Check history requires Professional or Enterprise plan')
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedDomainId(domainId)
|
||||
setLoadingHistory(true)
|
||||
try {
|
||||
const result = await api.getDomainHistory(domainId, 30)
|
||||
setDomainHistory(result.history)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load history')
|
||||
} finally {
|
||||
setLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return 'Not checked yet'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const formatExpirationDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const daysUntil = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (daysUntil < 0) return { text: 'Expired', urgent: true }
|
||||
if (daysUntil <= 7) return { text: `${daysUntil}d left`, urgent: true }
|
||||
if (daysUntil <= 30) return { text: `${daysUntil}d left`, urgent: false }
|
||||
return { text: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), urgent: false }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
const canAddMore = subscription
|
||||
? subscription.domains_used < subscription.domain_limit
|
||||
: true
|
||||
|
||||
const availableCount = domains.filter(d => d.is_available).length
|
||||
const expiringCount = domains.filter(d => {
|
||||
if (!d.expiration_date) return false
|
||||
const daysUntil = Math.ceil((new Date(d.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
|
||||
return daysUntil <= 30 && daysUntil > 0
|
||||
}).length
|
||||
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Starter'
|
||||
const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise'
|
||||
const isEnterprise = tierName === 'Enterprise'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 right-1/4 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-24 sm:pt-28 pb-12 sm:pb-16 px-4 sm:px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 sm:gap-6 mb-8 sm:mb-10 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-1.5 sm:mb-2">Your Watchlist</h1>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted">
|
||||
{subscription?.domains_used || 0} of {subscription?.domain_limit || 3} domains
|
||||
{availableCount > 0 && (
|
||||
<span className="text-accent ml-2">· {availableCount} available</span>
|
||||
)}
|
||||
{expiringCount > 0 && (
|
||||
<span className="text-warning ml-2">· {expiringCount} expiring soon</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<span className={clsx(
|
||||
"flex items-center gap-2 label sm:label-md px-2.5 sm:px-3 py-1 sm:py-1.5 border rounded-lg",
|
||||
isEnterprise ? "bg-accent-muted border-accent/20 text-accent" :
|
||||
isProOrHigher ? "bg-background-secondary border-border text-foreground-muted" :
|
||||
"bg-background-secondary border-border text-foreground-subtle"
|
||||
)}>
|
||||
{isEnterprise && <Crown className="w-3.5 h-3.5" />}
|
||||
{tierName} Plan
|
||||
</span>
|
||||
{!canAddMore && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-ui-sm sm:text-ui text-accent hover:text-accent-hover transition-colors flex items-center gap-1"
|
||||
>
|
||||
Upgrade
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards - For Pro+ users */}
|
||||
{isProOrHigher && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 mb-8 sm:mb-10 animate-slide-up">
|
||||
<div className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Tracked</span>
|
||||
</div>
|
||||
<p className="text-heading-sm sm:text-heading-md font-medium text-foreground">{domains.length}</p>
|
||||
</div>
|
||||
<div className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Check className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Available</span>
|
||||
</div>
|
||||
<p className="text-heading-sm sm:text-heading-md font-medium text-accent">{availableCount}</p>
|
||||
</div>
|
||||
<div className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Expiring</span>
|
||||
</div>
|
||||
<p className={clsx(
|
||||
"text-heading-sm sm:text-heading-md font-medium",
|
||||
expiringCount > 0 ? "text-warning" : "text-foreground"
|
||||
)}>{expiringCount}</p>
|
||||
</div>
|
||||
<div className="p-4 sm:p-5 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<div className="flex items-center gap-2 text-foreground-subtle mb-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span className="text-ui-sm">Check Freq</span>
|
||||
</div>
|
||||
<p className="text-heading-sm sm:text-heading-md font-medium text-foreground capitalize">
|
||||
{subscription?.check_frequency || 'Daily'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Domain Form */}
|
||||
<form onSubmit={handleAddDomain} className="mb-8 sm:mb-10 animate-slide-up delay-100">
|
||||
<div className="flex gap-2.5 sm:gap-3">
|
||||
<div className="flex-1 relative group">
|
||||
<div className="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 text-foreground-subtle
|
||||
group-focus-within:text-foreground-muted transition-colors duration-300">
|
||||
<Plus className="w-4 sm:w-5 h-4 sm:h-5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="Add domain to watchlist (e.g., example.com)"
|
||||
disabled={!canAddMore}
|
||||
className="w-full pl-11 sm:pl-14 pr-4 sm:pr-5 py-3 sm:py-4 bg-background-secondary border border-border rounded-xl sm:rounded-2xl
|
||||
text-body-sm sm:text-body text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-border-hover
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={adding || !newDomain.trim() || !canAddMore}
|
||||
className="px-4 sm:px-6 py-3 sm:py-4 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-all duration-300 flex items-center gap-2 sm:gap-2.5"
|
||||
>
|
||||
{adding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 sm:mt-4 text-body-xs sm:text-body-sm text-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto text-foreground-subtle hover:text-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.length === 0 ? (
|
||||
<div className="text-center py-16 sm:py-20 border border-dashed border-border rounded-xl sm:rounded-2xl bg-background-secondary/30 animate-slide-up delay-200">
|
||||
<div className="w-12 sm:w-14 h-12 sm:h-14 bg-background-tertiary rounded-xl flex items-center justify-center mx-auto mb-5 sm:mb-6">
|
||||
<Search className="w-5 sm:w-6 h-5 sm:h-6 text-foreground-subtle" />
|
||||
</div>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted mb-1.5 sm:mb-2">Your watchlist is empty</p>
|
||||
<p className="text-body-xs sm:text-body-sm text-foreground-subtle">
|
||||
Add your first domain above to start monitoring
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-xl sm:rounded-2xl overflow-hidden bg-background-secondary/30 animate-slide-up delay-200">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-background-secondary/50">
|
||||
<th className="text-left label sm:label-md px-4 sm:px-5 py-3 sm:py-4">Domain</th>
|
||||
<th className="text-left label sm:label-md px-4 sm:px-5 py-3 sm:py-4 hidden sm:table-cell">Status</th>
|
||||
{isProOrHigher && (
|
||||
<th className="text-left label sm:label-md px-4 sm:px-5 py-3 sm:py-4 hidden lg:table-cell">Expiration</th>
|
||||
)}
|
||||
<th className="text-left label sm:label-md px-4 sm:px-5 py-3 sm:py-4 hidden md:table-cell">Last Check</th>
|
||||
<th className="text-right label sm:label-md px-4 sm:px-5 py-3 sm:py-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{domains.map((domain) => {
|
||||
const expiration = formatExpirationDate(domain.expiration_date)
|
||||
return (
|
||||
<tr key={domain.id} className="group hover:bg-background-secondary/50 transition-colors duration-300">
|
||||
<td className="px-4 sm:px-5 py-3 sm:py-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
domain.is_available ? "bg-accent" : "bg-foreground-subtle"
|
||||
)} />
|
||||
<span className="font-mono text-body-sm sm:text-body">{domain.name}</span>
|
||||
{domain.is_available && (
|
||||
<span className="sm:hidden text-ui-xs text-accent px-2 py-0.5 bg-accent-muted rounded-full">
|
||||
Available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-5 py-3 sm:py-4 hidden sm:table-cell">
|
||||
<span className={clsx(
|
||||
"text-ui-sm sm:text-ui px-2.5 sm:px-3 py-0.5 sm:py-1 rounded-full",
|
||||
domain.is_available
|
||||
? "text-accent bg-accent-muted"
|
||||
: "text-foreground-muted bg-background-tertiary"
|
||||
)}>
|
||||
{domain.is_available ? 'Available' : 'Registered'}
|
||||
</span>
|
||||
</td>
|
||||
{isProOrHigher && (
|
||||
<td className="px-4 sm:px-5 py-3 sm:py-4 hidden lg:table-cell">
|
||||
{expiration ? (
|
||||
<span className={clsx(
|
||||
"text-body-xs sm:text-body-sm flex items-center gap-1.5",
|
||||
expiration.urgent ? "text-warning" : "text-foreground-subtle"
|
||||
)}>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{expiration.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-xs sm:text-body-sm text-foreground-subtle">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 sm:px-5 py-3 sm:py-4 hidden md:table-cell">
|
||||
<span className="text-body-xs sm:text-body-sm text-foreground-subtle flex items-center gap-1.5 sm:gap-2">
|
||||
<Clock className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
{formatDate(domain.last_checked)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-5 py-3 sm:py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{isProOrHigher && (
|
||||
<button
|
||||
onClick={() => loadDomainHistory(domain.id)}
|
||||
className="p-2 sm:p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary
|
||||
rounded-lg transition-all duration-300"
|
||||
title="View history"
|
||||
>
|
||||
<History className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRefresh(domain.id)}
|
||||
disabled={refreshingId === domain.id}
|
||||
className="p-2 sm:p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary
|
||||
rounded-lg transition-all duration-300"
|
||||
title="Refresh now"
|
||||
>
|
||||
<RefreshCw className={clsx(
|
||||
"w-3.5 sm:w-4 h-3.5 sm:h-4",
|
||||
refreshingId === domain.id && "animate-spin"
|
||||
)} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.id)}
|
||||
className="p-2 sm:p-2.5 text-foreground-subtle hover:text-danger hover:bg-danger-muted
|
||||
rounded-lg transition-all duration-300"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features based on plan */}
|
||||
{subscription && (
|
||||
<div className="mt-8 sm:mt-10 grid sm:grid-cols-2 lg:grid-cols-3 gap-4 animate-slide-up delay-300">
|
||||
{/* Check Frequency */}
|
||||
<div className="p-4 sm:p-5 rounded-xl border bg-background-secondary/30 border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-foreground-muted" />
|
||||
<span className="text-body-sm font-medium text-foreground">Check Frequency</span>
|
||||
{isEnterprise && (
|
||||
<span className="text-ui-xs text-accent bg-accent-muted px-1.5 py-0.5 rounded ml-auto">Hourly</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-xs text-foreground-muted capitalize">
|
||||
{subscription.check_frequency || 'Daily'} availability checks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
<div className={clsx(
|
||||
"p-4 sm:p-5 rounded-xl border",
|
||||
isProOrHigher ? "bg-background-secondary/30 border-border" : "bg-background-secondary/20 border-border-subtle opacity-60"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<History className={clsx("w-4 h-4", isProOrHigher ? "text-foreground-muted" : "text-foreground-subtle")} />
|
||||
<span className="text-body-sm font-medium text-foreground">Check History</span>
|
||||
{!isProOrHigher && (
|
||||
<span className="text-ui-xs text-accent ml-auto">Pro+</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-xs text-foreground-muted">
|
||||
{isProOrHigher
|
||||
? `${subscription.history_days === -1 ? 'Unlimited' : `${subscription.history_days} days`} history`
|
||||
: 'Upgrade for history access'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Access */}
|
||||
<div className={clsx(
|
||||
"p-4 sm:p-5 rounded-xl border",
|
||||
isEnterprise ? "bg-background-secondary/30 border-border" : "bg-background-secondary/20 border-border-subtle opacity-60"
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Crown className={clsx("w-4 h-4", isEnterprise ? "text-accent" : "text-foreground-subtle")} />
|
||||
<span className="text-body-sm font-medium text-foreground">API Access</span>
|
||||
{!isEnterprise && (
|
||||
<span className="text-ui-xs text-accent ml-auto">Enterprise</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-body-xs text-foreground-muted">
|
||||
{isEnterprise
|
||||
? 'Full API access enabled'
|
||||
: 'Upgrade for API access'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Pricing CTA */}
|
||||
<div className="mt-8 sm:mt-10 p-5 sm:p-6 bg-background-secondary/30 border border-border rounded-xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 animate-slide-up delay-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-accent-muted rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">TLD Price Intelligence</p>
|
||||
<p className="text-body-xs text-foreground-muted">Track domain extension pricing trends</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="text-ui-sm text-accent hover:text-accent-hover transition-colors flex items-center gap-1"
|
||||
>
|
||||
Explore Pricing
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mt-6 sm:mt-8 text-ui-xs sm:text-ui text-foreground-subtle text-center">
|
||||
Domains are checked automatically {subscription?.check_frequency === 'hourly' ? 'every hour' : 'every day at 06:00 UTC'}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Domain History Modal */}
|
||||
{selectedDomainId && domainHistory && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background-secondary border border-border rounded-2xl max-w-md w-full max-h-[80vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-body-lg font-medium text-foreground">Check History</h3>
|
||||
<p className="text-body-sm text-foreground-muted">
|
||||
{domains.find(d => d.id === selectedDomainId)?.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDomainId(null)
|
||||
setDomainHistory(null)
|
||||
}}
|
||||
className="p-2 text-foreground-subtle hover:text-foreground hover:bg-background-tertiary rounded-lg transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingHistory ? (
|
||||
<div className="py-8 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||
</div>
|
||||
) : domainHistory.length === 0 ? (
|
||||
<p className="py-8 text-center text-body-sm text-foreground-muted">No check history yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{domainHistory.map((check) => (
|
||||
<div
|
||||
key={check.id}
|
||||
className="flex items-center justify-between p-3 bg-background-tertiary rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
"w-2 h-2 rounded-full",
|
||||
check.is_available ? "bg-accent" : "bg-foreground-subtle"
|
||||
)} />
|
||||
<span className={clsx(
|
||||
"text-body-sm",
|
||||
check.is_available ? "text-accent" : "text-foreground-muted"
|
||||
)}>
|
||||
{check.is_available ? 'Available' : 'Registered'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-body-xs text-foreground-subtle">
|
||||
{formatDate(check.checked_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
150
frontend/src/app/globals.css
Normal file
150
frontend/src/app/globals.css
Normal file
@ -0,0 +1,150 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-accent/20 text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Display typography */
|
||||
.font-display {
|
||||
font-family: var(--font-display), Georgia, serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Elegant input field */
|
||||
.input-elegant {
|
||||
@apply w-full px-5 py-4 bg-background-secondary border border-border rounded-2xl
|
||||
text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none focus:border-border-hover;
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.input-elegant:focus {
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.1), 0 0 40px -10px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* Primary button with glow */
|
||||
.btn-primary {
|
||||
@apply px-6 py-3 bg-accent text-background font-medium rounded-xl
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):hover {
|
||||
@apply bg-accent-hover;
|
||||
box-shadow: 0 0 40px -8px rgba(16, 185, 129, 0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:not(:disabled):active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
/* Secondary button */
|
||||
.btn-secondary {
|
||||
@apply px-6 py-3 bg-background-tertiary text-foreground font-medium rounded-xl
|
||||
border border-border disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
@apply border-border-hover bg-background-elevated;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-background-secondary border border-border rounded-3xl p-8;
|
||||
transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
@apply border-border-hover;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
@apply card;
|
||||
box-shadow: 0 0 80px -20px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-available {
|
||||
@apply text-accent bg-accent-muted;
|
||||
}
|
||||
|
||||
.status-taken {
|
||||
@apply text-foreground-muted bg-background-tertiary;
|
||||
}
|
||||
|
||||
/* Label style */
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
@apply text-foreground-subtle;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
@apply h-px bg-gradient-to-r from-transparent via-border-hover to-transparent;
|
||||
}
|
||||
|
||||
/* Glow effect utilities */
|
||||
.glow-accent {
|
||||
box-shadow: 0 0 100px -30px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.glow-accent-strong {
|
||||
box-shadow: 0 0 120px -20px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-border rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-border-hover;
|
||||
}
|
||||
|
||||
/* Animation delays */
|
||||
.delay-100 { animation-delay: 100ms; }
|
||||
.delay-150 { animation-delay: 150ms; }
|
||||
.delay-200 { animation-delay: 200ms; }
|
||||
.delay-250 { animation-delay: 250ms; }
|
||||
.delay-300 { animation-delay: 300ms; }
|
||||
.delay-400 { animation-delay: 400ms; }
|
||||
.delay-500 { animation-delay: 500ms; }
|
||||
37
frontend/src/app/layout.tsx
Normal file
37
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter, JetBrains_Mono, Playfair_Display } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
})
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'pounce — Domain Availability Monitoring',
|
||||
description: 'Track and monitor domain name availability. Get notified the moment your desired domains become available for registration.',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable} ${playfair.variable}`}>
|
||||
<body className="bg-background text-foreground antialiased font-sans selection:bg-accent/20 selection:text-foreground">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
141
frontend/src/app/login/page.tsx
Normal file
141
frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Loader2, ArrowRight, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
// Logo Component - Text with accent dot
|
||||
function Logo() {
|
||||
return (
|
||||
<div className="relative inline-flex items-baseline">
|
||||
{/* Accent dot - top left */}
|
||||
<span className="absolute top-1 -left-3.5 flex items-center justify-center">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-accent" />
|
||||
<span className="absolute w-2.5 h-2.5 rounded-full bg-accent animate-ping opacity-30" />
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight text-foreground">
|
||||
pounce
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const { login } = useStore()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-sm animate-fade-in">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex justify-center mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-10">
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">Welcome back</h1>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted">
|
||||
Sign in to access your watchlist
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 sm:p-4 bg-danger-muted border border-danger/20 rounded-2xl">
|
||||
<p className="text-danger text-body-xs sm:text-body-sm text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5 sm:space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email address"
|
||||
required
|
||||
className="input-elegant text-body-sm sm:text-body"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
minLength={8}
|
||||
className="input-elegant text-body-sm sm:text-body pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground transition-colors duration-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 sm:py-4 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-300 flex items-center justify-center gap-2 sm:gap-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Continue
|
||||
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<p className="mt-8 sm:mt-10 text-center text-body-xs sm:text-body-sm text-foreground-muted">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/register" className="text-foreground hover:text-accent transition-colors duration-300">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
438
frontend/src/app/page.tsx
Normal file
438
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,438 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { DomainChecker } from '@/components/DomainChecker'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import { Eye, Bell, Clock, Shield, ArrowRight, Check, TrendingUp, TrendingDown, Minus, Lock, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Eye,
|
||||
title: 'Continuous Monitoring',
|
||||
description: 'Automated WHOIS and DNS queries check your domains daily.',
|
||||
},
|
||||
{
|
||||
icon: Bell,
|
||||
title: 'Instant Notifications',
|
||||
description: 'Get notified the moment a domain becomes available.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Expiration Tracking',
|
||||
description: 'Track expiration dates and plan ahead for domain drops.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Private & Secure',
|
||||
description: 'Self-hosted infrastructure. Your strategy stays confidential.',
|
||||
},
|
||||
]
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: '0',
|
||||
period: '',
|
||||
description: 'For individuals exploring domains',
|
||||
features: ['3 domains', 'Daily checks', 'Email alerts', 'Basic WHOIS'],
|
||||
cta: 'Start Free',
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
price: '4.99',
|
||||
period: '/mo',
|
||||
description: 'For domain investors',
|
||||
features: ['25 domains', 'Daily checks', 'Priority alerts', 'Full WHOIS', '30-day history'],
|
||||
cta: 'Get Pro',
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '9.99',
|
||||
period: '/mo',
|
||||
description: 'For agencies & portfolios',
|
||||
features: ['100 domains', 'Hourly checks', 'Priority alerts', 'Full WHOIS', 'Unlimited history', 'API access'],
|
||||
cta: 'Get Enterprise',
|
||||
highlighted: false,
|
||||
},
|
||||
]
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
trend: string
|
||||
}
|
||||
|
||||
interface TrendingTld {
|
||||
tld: string
|
||||
reason: string
|
||||
current_price: number
|
||||
price_change: number // API returns price_change, not price_change_percent
|
||||
}
|
||||
|
||||
// Shimmer component for locked content
|
||||
function ShimmerBlock({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"relative overflow-hidden rounded bg-background-tertiary",
|
||||
className
|
||||
)}>
|
||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
const [tldData, setTldData] = useState<TldData[]>([])
|
||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
fetchTldData()
|
||||
}, [checkAuth])
|
||||
|
||||
const fetchTldData = async () => {
|
||||
try {
|
||||
const [overview, trending] = await Promise.all([
|
||||
api.getTldOverview(8),
|
||||
api.getTrendingTlds()
|
||||
])
|
||||
setTldData(overview.tlds)
|
||||
setTrendingTlds(trending.trending.slice(0, 4))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TLD data:', error)
|
||||
} finally {
|
||||
setLoadingTlds(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getTrendIcon = (priceChange: number) => {
|
||||
if (priceChange > 0) return <TrendingUp className="w-3.5 h-3.5" />
|
||||
if (priceChange < 0) return <TrendingDown className="w-3.5 h-3.5" />
|
||||
return <Minus className="w-3.5 h-3.5" />
|
||||
}
|
||||
|
||||
const getTrendDirection = (priceChange: number) => {
|
||||
if (priceChange > 0) return 'up'
|
||||
if (priceChange < 0) return 'down'
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Ambient background glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-accent/[0.03] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-32 sm:pt-36 md:pt-40 lg:pt-48 pb-16 sm:pb-20 px-4 sm:px-6">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
{/* Tagline */}
|
||||
<div className="inline-flex items-center gap-2 sm:gap-2.5 px-3 sm:px-4 py-1.5 sm:py-2 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6 sm:mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent animate-glow-pulse" />
|
||||
<span className="text-ui-sm sm:text-ui text-foreground-muted">Domain Availability Monitoring</span>
|
||||
</div>
|
||||
|
||||
{/* Main Headline - RESPONSIVE */}
|
||||
<h1 className="font-display text-[2.25rem] leading-[1.1] sm:text-[3rem] md:text-[3.75rem] lg:text-[4.5rem] xl:text-[5.25rem] tracking-[-0.035em] mb-6 sm:mb-8 md:mb-10 animate-slide-up">
|
||||
<span className="block text-foreground">The domains you want.</span>
|
||||
<span className="block text-foreground-muted">The moment they're free.</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline - RESPONSIVE */}
|
||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted max-w-xl sm:max-w-2xl mx-auto mb-10 sm:mb-12 md:mb-16 animate-slide-up delay-100 px-4 sm:px-0">
|
||||
Monitor any domain. Track expiration dates. Get notified instantly
|
||||
when your target domains become available for registration.
|
||||
</p>
|
||||
|
||||
{/* Domain Checker */}
|
||||
<div className="animate-slide-up delay-150">
|
||||
<DomainChecker />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* TLD Price Intelligence Section */}
|
||||
<section className="relative py-16 sm:py-20 md:py-24 px-4 sm:px-6 border-t border-border-subtle">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-10 sm:mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-5">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui-sm text-accent">Market Insights</span>
|
||||
</div>
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
|
||||
TLD Price Intelligence
|
||||
</h2>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted max-w-lg mx-auto">
|
||||
Track how domain extension prices evolve. Compare registrars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Trending TLDs - Card Grid */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui font-medium text-foreground">Trending Now</span>
|
||||
</div>
|
||||
|
||||
{loadingTlds ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-5 bg-background-secondary border border-border rounded-xl">
|
||||
<ShimmerBlock className="h-6 w-16 mb-3" />
|
||||
<ShimmerBlock className="h-4 w-full mb-2" />
|
||||
<ShimmerBlock className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{trendingTlds.map((item) => (
|
||||
<Link
|
||||
key={item.tld}
|
||||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : '/register'}
|
||||
className="group p-5 bg-background-secondary border border-border rounded-xl
|
||||
hover:border-border-hover transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
||||
<span className={clsx(
|
||||
"flex items-center gap-1 text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
||||
(item.price_change ?? 0) > 0
|
||||
? "text-[#f97316] bg-[#f9731615]"
|
||||
: (item.price_change ?? 0) < 0
|
||||
? "text-accent bg-accent-muted"
|
||||
: "text-foreground-muted bg-background-tertiary"
|
||||
)}>
|
||||
{getTrendIcon(item.price_change ?? 0)}
|
||||
{(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-body-xs text-foreground-subtle mb-3 line-clamp-2">{item.reason}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm text-foreground">${(item.current_price ?? 0).toFixed(2)}/yr</span>
|
||||
) : (
|
||||
<ShimmerBlock className="h-5 w-20" />
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Login CTA for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent-muted rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Unlock Full Data</p>
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
Sign in for prices, trends, and registrar comparisons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
>
|
||||
Get Started Free
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="inline-flex items-center gap-2 text-body-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
Explore All TLDs
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 sm:mb-16 md:mb-20">
|
||||
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">How It Works</p>
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
|
||||
Professional domain intelligence
|
||||
</h2>
|
||||
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted max-w-md sm:max-w-lg mx-auto px-4 sm:px-0">
|
||||
Everything you need to secure high-value domains before anyone else.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
||||
{features.map((feature, i) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="group p-5 sm:p-6 rounded-2xl border border-transparent hover:border-border hover:bg-background-secondary/50 transition-all duration-500"
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
>
|
||||
<div className="w-10 sm:w-11 h-10 sm:h-11 bg-background-secondary border border-border rounded-xl flex items-center justify-center mb-4 sm:mb-5
|
||||
group-hover:border-accent/30 group-hover:bg-accent/5 transition-all duration-500">
|
||||
<feature.icon className="w-4 sm:w-5 h-4 sm:h-5 text-foreground-muted group-hover:text-accent transition-colors duration-500" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2">{feature.title}</h3>
|
||||
<p className="text-body-xs sm:text-body-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
||||
{/* Section glow */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12 sm:mb-16 md:mb-20">
|
||||
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">Pricing</p>
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
|
||||
Simple, transparent plans
|
||||
</h2>
|
||||
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted">
|
||||
Start free. Upgrade as your portfolio grows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-5">
|
||||
{tiers.map((tier, i) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className={`relative p-5 sm:p-6 md:p-7 rounded-2xl border transition-all duration-500 ${
|
||||
tier.highlighted
|
||||
? 'bg-background-secondary border-accent/20 glow-accent'
|
||||
: 'bg-background-secondary/50 border-border hover:border-border-hover'
|
||||
}`}
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="px-3 py-1 bg-accent text-background text-ui-xs sm:text-ui-sm font-medium rounded-full">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 sm:mb-6">
|
||||
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-1">{tier.name}</h3>
|
||||
<p className="text-ui-sm sm:text-ui text-foreground-subtle mb-4 sm:mb-5">{tier.description}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
{tier.price === '0' ? (
|
||||
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">Free</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">${tier.price}</span>
|
||||
<span className="text-body-sm text-foreground-subtle">{tier.period}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2.5 sm:gap-3 text-body-xs sm:text-body-sm">
|
||||
<Check className="w-3.5 sm:w-4 h-3.5 sm:h-4 text-accent shrink-0" strokeWidth={2.5} />
|
||||
<span className="text-foreground-muted">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
className={`w-full flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-xl text-ui-sm sm:text-ui font-medium transition-all duration-300 ${
|
||||
tier.highlighted
|
||||
? 'bg-accent text-background hover:bg-accent-hover'
|
||||
: 'bg-background-tertiary text-foreground border border-border hover:border-border-hover'
|
||||
}`}
|
||||
>
|
||||
{tier.cta}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
|
||||
Start monitoring today
|
||||
</h2>
|
||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted mb-8 sm:mb-10">
|
||||
Create a free account and track up to 3 domains.
|
||||
No credit card required.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="btn-primary inline-flex items-center gap-2 sm:gap-2.5 px-6 sm:px-8 py-3 sm:py-4 text-body-sm sm:text-body"
|
||||
>
|
||||
Get Started Free
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 sm:py-10 px-4 sm:px-6 border-t border-border-subtle">
|
||||
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 sm:gap-6">
|
||||
<p className="text-ui-sm sm:text-ui text-foreground-subtle">
|
||||
© 2024 pounce
|
||||
</p>
|
||||
<div className="flex items-center gap-6 sm:gap-8">
|
||||
<Link href="/tld-pricing" className="text-ui-sm sm:text-ui text-foreground-subtle hover:text-foreground transition-colors duration-300">
|
||||
TLD Pricing
|
||||
</Link>
|
||||
<Link href="/pricing" className="text-ui-sm sm:text-ui text-foreground-subtle hover:text-foreground transition-colors duration-300">
|
||||
Plans
|
||||
</Link>
|
||||
<Link href="/login" className="text-ui-sm sm:text-ui text-foreground-subtle hover:text-foreground transition-colors duration-300">
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
frontend/src/app/pricing/page.tsx
Normal file
224
frontend/src/app/pricing/page.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Check, ArrowRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const tiers = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: '0',
|
||||
period: '',
|
||||
description: 'Perfect for exploring domain opportunities',
|
||||
features: [
|
||||
'3 domains in watchlist',
|
||||
'Daily availability checks',
|
||||
'Email notifications',
|
||||
'Basic WHOIS data',
|
||||
],
|
||||
cta: 'Start Free',
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
price: '4.99',
|
||||
period: '/month',
|
||||
description: 'For domain investors and growing businesses',
|
||||
features: [
|
||||
'25 domains in watchlist',
|
||||
'Daily availability checks',
|
||||
'Priority email notifications',
|
||||
'Full WHOIS data',
|
||||
'30-day check history',
|
||||
'Expiration date tracking',
|
||||
],
|
||||
cta: 'Get Professional',
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '9.99',
|
||||
period: '/month',
|
||||
description: 'For agencies and large portfolios',
|
||||
features: [
|
||||
'100 domains in watchlist',
|
||||
'Hourly availability checks',
|
||||
'Priority notifications',
|
||||
'Full WHOIS data',
|
||||
'Unlimited check history',
|
||||
'Expiration date tracking',
|
||||
'REST API access',
|
||||
'Webhook integrations',
|
||||
],
|
||||
cta: 'Get Enterprise',
|
||||
highlighted: false,
|
||||
},
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: 'How does domain monitoring work?',
|
||||
a: 'We perform automated WHOIS and DNS queries on your watchlist domains. When a domain\'s status changes, you\'ll receive an instant notification.',
|
||||
},
|
||||
{
|
||||
q: 'Can I upgrade or downgrade?',
|
||||
a: 'Yes. You can change your plan at any time. When upgrading, you\'ll get immediate access to additional features.',
|
||||
},
|
||||
{
|
||||
q: 'What payment methods do you accept?',
|
||||
a: 'We accept all major credit cards and PayPal. All transactions are secured with 256-bit SSL encryption.',
|
||||
},
|
||||
{
|
||||
q: 'Is there a money-back guarantee?',
|
||||
a: 'Yes. If you\'re not satisfied within the first 14 days, we\'ll provide a full refund—no questions asked.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function PricingPage() {
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-36 md:pt-40 lg:pt-48 pb-16 sm:pb-20 md:pb-24 px-4 sm:px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 sm:mb-16 md:mb-20">
|
||||
<div className="inline-flex items-center gap-2 sm:gap-2.5 px-3 sm:px-4 py-1.5 sm:py-2 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6 sm:mb-8 md:mb-10 animate-fade-in">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent animate-glow-pulse" />
|
||||
<span className="text-ui-sm sm:text-ui text-foreground-muted">Simple Pricing</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2.25rem] leading-[1.1] sm:text-[3rem] md:text-[3.75rem] lg:text-[4.5rem] tracking-[-0.035em] mb-4 sm:mb-5 md:mb-6 animate-slide-up">
|
||||
<span className="text-foreground">Choose your plan</span>
|
||||
</h1>
|
||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted max-w-lg sm:max-w-xl mx-auto animate-slide-up delay-100 px-4 sm:px-0">
|
||||
Start free and upgrade as your domain portfolio grows.
|
||||
All plans include core monitoring features.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-5 mb-20 sm:mb-24 md:mb-32">
|
||||
{tiers.map((tier, i) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className={`relative p-5 sm:p-6 md:p-7 rounded-2xl border transition-all duration-500 animate-slide-up ${
|
||||
tier.highlighted
|
||||
? 'bg-background-secondary border-accent/20 glow-accent'
|
||||
: 'bg-background-secondary/50 border-border hover:border-border-hover'
|
||||
}`}
|
||||
style={{ animationDelay: `${150 + i * 100}ms` }}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className="px-3 py-1 bg-accent text-background text-ui-xs sm:text-ui-sm font-medium rounded-full">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 sm:mb-6">
|
||||
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-1">{tier.name}</h3>
|
||||
<p className="text-ui-sm sm:text-ui text-foreground-subtle mb-4 sm:mb-5 min-h-[40px]">{tier.description}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
{tier.price === '0' ? (
|
||||
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">Free</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">${tier.price}</span>
|
||||
<span className="text-body-sm text-foreground-subtle">{tier.period}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2.5 sm:gap-3 text-body-xs sm:text-body-sm">
|
||||
<Check className="w-3.5 sm:w-4 h-3.5 sm:h-4 text-accent shrink-0 mt-0.5" strokeWidth={2.5} />
|
||||
<span className="text-foreground-muted">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
className={`w-full flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-xl text-ui-sm sm:text-ui font-medium transition-all duration-300 ${
|
||||
tier.highlighted
|
||||
? 'bg-accent text-background hover:bg-accent-hover'
|
||||
: 'bg-background-tertiary text-foreground border border-border hover:border-border-hover'
|
||||
}`}
|
||||
>
|
||||
{tier.cta}
|
||||
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-8 sm:mb-10 md:mb-12">
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-3 sm:mb-4">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted">
|
||||
Everything you need to know about pounce.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{faqs.map((faq, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-2xl
|
||||
hover:border-border-hover transition-all duration-300"
|
||||
>
|
||||
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2 sm:mb-3">{faq.q}</h3>
|
||||
<p className="text-body-xs sm:text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 sm:py-10 px-4 sm:px-6 border-t border-border-subtle">
|
||||
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 sm:gap-6">
|
||||
<p className="text-ui-sm sm:text-ui text-foreground-subtle">
|
||||
© 2024 pounce
|
||||
</p>
|
||||
<div className="flex items-center gap-6 sm:gap-8">
|
||||
<Link href="/" className="text-ui-sm sm:text-ui text-foreground-subtle hover:text-foreground transition-colors duration-300">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/login" className="text-ui-sm sm:text-ui text-foreground-subtle hover:text-foreground transition-colors duration-300">
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
frontend/src/app/register/page.tsx
Normal file
182
frontend/src/app/register/page.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { Loader2, ArrowRight, Check, Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
// Logo Component - Text with accent dot
|
||||
function Logo() {
|
||||
return (
|
||||
<div className="relative inline-flex items-baseline">
|
||||
{/* Accent dot - top left */}
|
||||
<span className="absolute top-1 -left-3.5 flex items-center justify-center">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-accent" />
|
||||
<span className="absolute w-2.5 h-2.5 rounded-full bg-accent animate-ping opacity-30" />
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight text-foreground">
|
||||
pounce
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const benefits = [
|
||||
'Monitor up to 3 domains free',
|
||||
'Daily availability checks',
|
||||
'Instant email notifications',
|
||||
'Track expiration dates',
|
||||
]
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const { register } = useStore()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await register(email, password)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/3 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Left Panel - Form */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12">
|
||||
<div className="relative w-full max-w-sm animate-fade-in">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="block mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8 sm:mb-10">
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">Create your account</h1>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted">
|
||||
Start monitoring domains in under a minute
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 sm:p-4 bg-danger-muted border border-danger/20 rounded-2xl">
|
||||
<p className="text-danger text-body-xs sm:text-body-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2.5 sm:space-y-3">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Email address"
|
||||
required
|
||||
className="input-elegant text-body-sm sm:text-body"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create password (min. 8 characters)"
|
||||
required
|
||||
minLength={8}
|
||||
className="input-elegant text-body-sm sm:text-body pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground transition-colors duration-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 sm:py-4 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-xl
|
||||
hover:bg-foreground/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-300 flex items-center justify-center gap-2 sm:gap-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Get Started
|
||||
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" className="text-foreground hover:text-accent transition-colors duration-300">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Benefits */}
|
||||
<div className="hidden lg:flex flex-1 bg-background-secondary/50 items-center justify-center p-8 xl:p-12 border-l border-border-subtle">
|
||||
<div className="max-w-sm animate-slide-up delay-200">
|
||||
<div className="inline-flex items-center gap-2 sm:gap-2.5 px-3 sm:px-4 py-1.5 sm:py-2 bg-background-tertiary border border-border rounded-full mb-8 sm:mb-10">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
<span className="text-ui-sm sm:text-ui text-foreground-muted">Free Plan Included</span>
|
||||
</div>
|
||||
|
||||
<h2 className="font-display text-[1.75rem] sm:text-[2.25rem] md:text-[2.75rem] leading-[1.15] tracking-[-0.025em] text-foreground mb-8 sm:mb-10">
|
||||
Everything you need to get started
|
||||
</h2>
|
||||
|
||||
<ul className="space-y-4 sm:space-y-5">
|
||||
{benefits.map((item) => (
|
||||
<li key={item} className="flex items-center gap-3 sm:gap-4">
|
||||
<div className="w-8 sm:w-9 h-8 sm:h-9 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<Check className="w-3.5 sm:w-4 h-3.5 sm:h-4 text-accent" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-body-sm sm:text-body text-foreground-muted">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="divider my-8 sm:my-10" />
|
||||
|
||||
<p className="text-body-xs sm:text-body-sm text-foreground-subtle">
|
||||
No credit card required. Upgrade anytime.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
404
frontend/src/app/tld-pricing/[tld]/page.tsx
Normal file
404
frontend/src/app/tld-pricing/[tld]/page.tsx
Normal file
@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Header } from '@/components/Header'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Calendar,
|
||||
Globe,
|
||||
Building,
|
||||
DollarSign,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldDetails {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
registry: string
|
||||
introduced: number
|
||||
trend: string
|
||||
trend_reason: string
|
||||
pricing: {
|
||||
avg: number
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
registrars: Array<{
|
||||
name: string
|
||||
registration_price: number
|
||||
renewal_price: number
|
||||
transfer_price: number
|
||||
}>
|
||||
cheapest_registrar: string
|
||||
}
|
||||
|
||||
interface TldHistory {
|
||||
tld: string
|
||||
current_price: number
|
||||
price_change_7d: number
|
||||
price_change_30d: number
|
||||
price_change_90d: number
|
||||
history: Array<{
|
||||
date: string
|
||||
price: number
|
||||
}>
|
||||
}
|
||||
|
||||
export default function TldDetailPage() {
|
||||
const params = useParams()
|
||||
const tld = params.tld as string
|
||||
|
||||
const [details, setDetails] = useState<TldDetails | null>(null)
|
||||
const [history, setHistory] = useState<TldHistory | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (tld) {
|
||||
loadData()
|
||||
}
|
||||
}, [tld])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [historyData, compareData] = await Promise.all([
|
||||
api.getTldHistory(tld, 90),
|
||||
api.getTldCompare(tld),
|
||||
])
|
||||
|
||||
// Build details from API data
|
||||
if (historyData && compareData) {
|
||||
const registrars = compareData.registrars || []
|
||||
const prices = registrars.map(r => r.registration_price)
|
||||
const avgPrice = prices.length > 0 ? prices.reduce((a, b) => a + b, 0) / prices.length : 0
|
||||
|
||||
setDetails({
|
||||
tld: tld,
|
||||
type: 'generic',
|
||||
description: `Domain extension .${tld}`,
|
||||
registry: 'Various',
|
||||
introduced: 2020,
|
||||
trend: historyData.price_change_30d > 0 ? 'up' : historyData.price_change_30d < 0 ? 'down' : 'stable',
|
||||
trend_reason: 'Price tracking available',
|
||||
pricing: {
|
||||
avg: avgPrice,
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices),
|
||||
},
|
||||
registrars: registrars.sort((a, b) => a.registration_price - b.registration_price),
|
||||
cheapest_registrar: registrars[0]?.name || 'N/A',
|
||||
})
|
||||
setHistory(historyData)
|
||||
} else {
|
||||
setError('Failed to load TLD data')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load TLD data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-5 h-5 text-warning" />
|
||||
case 'down':
|
||||
return <TrendingDown className="w-5 h-5 text-accent" />
|
||||
default:
|
||||
return <Minus className="w-5 h-5 text-foreground-subtle" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !details) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="pt-28 pb-16 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<p className="text-body-lg text-foreground-muted mb-4">{error || 'TLD not found'}</p>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to TLD Overview
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/3 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="inline-flex items-center gap-2 text-body-sm text-foreground-muted hover:text-foreground mb-8 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
All TLDs
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-10 sm:mb-12 animate-fade-in">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h1 className="font-mono text-[3rem] sm:text-[4rem] md:text-[5rem] leading-none text-foreground mb-2">
|
||||
.{details.tld}
|
||||
</h1>
|
||||
<p className="text-body-lg text-foreground-muted">{details.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-3 bg-background-secondary border border-border rounded-xl">
|
||||
{getTrendIcon(details.trend)}
|
||||
<span className={clsx(
|
||||
"text-body-sm font-medium",
|
||||
details.trend === 'up' ? "text-warning" :
|
||||
details.trend === 'down' ? "text-accent" :
|
||||
"text-foreground-muted"
|
||||
)}>
|
||||
{details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground-subtle">{details.trend_reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Price Stats */}
|
||||
<div className="grid sm:grid-cols-3 gap-4 mb-10 sm:mb-12 animate-slide-up">
|
||||
<div className="p-5 sm:p-6 bg-background-secondary border border-border rounded-xl">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-2">Average Price</p>
|
||||
<p className="text-heading-md sm:text-heading-lg font-medium text-foreground">
|
||||
${details.pricing.avg.toFixed(2)}<span className="text-body-sm text-foreground-subtle">/yr</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 sm:p-6 bg-background-secondary border border-border rounded-xl">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-2">Cheapest</p>
|
||||
<p className="text-heading-md sm:text-heading-lg font-medium text-accent">
|
||||
${details.pricing.min.toFixed(2)}<span className="text-body-sm text-foreground-subtle">/yr</span>
|
||||
</p>
|
||||
<p className="text-ui-sm text-foreground-subtle mt-1">at {details.cheapest_registrar}</p>
|
||||
</div>
|
||||
<div className="p-5 sm:p-6 bg-background-secondary border border-border rounded-xl">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-2">Price Range</p>
|
||||
<p className="text-heading-md sm:text-heading-lg font-medium text-foreground">
|
||||
${details.pricing.min.toFixed(2)} - ${details.pricing.max.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
{history && (
|
||||
<div className="mb-10 sm:mb-12 animate-slide-up delay-100">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Price Changes</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl text-center">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-1">7 Days</p>
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium",
|
||||
history.price_change_7d > 0 ? "text-warning" :
|
||||
history.price_change_7d < 0 ? "text-accent" :
|
||||
"text-foreground-muted"
|
||||
)}>
|
||||
{history.price_change_7d > 0 ? '+' : ''}{history.price_change_7d.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl text-center">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-1">30 Days</p>
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium",
|
||||
history.price_change_30d > 0 ? "text-warning" :
|
||||
history.price_change_30d < 0 ? "text-accent" :
|
||||
"text-foreground-muted"
|
||||
)}>
|
||||
{history.price_change_30d > 0 ? '+' : ''}{history.price_change_30d.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-background-secondary/50 border border-border rounded-xl text-center">
|
||||
<p className="text-ui-sm text-foreground-subtle mb-1">90 Days</p>
|
||||
<p className={clsx(
|
||||
"text-body-lg font-medium",
|
||||
history.price_change_90d > 0 ? "text-warning" :
|
||||
history.price_change_90d < 0 ? "text-accent" :
|
||||
"text-foreground-muted"
|
||||
)}>
|
||||
{history.price_change_90d > 0 ? '+' : ''}{history.price_change_90d.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price Chart */}
|
||||
{history && history.history.length > 0 && (
|
||||
<div className="mb-10 sm:mb-12 animate-slide-up delay-150">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">90-Day Price History</h2>
|
||||
<div className="p-6 bg-background-secondary border border-border rounded-xl">
|
||||
<div className="h-40 flex items-end gap-0.5">
|
||||
{history.history.map((point, i) => {
|
||||
const prices = history.history.map(p => p.price)
|
||||
const minPrice = Math.min(...prices)
|
||||
const maxPrice = Math.max(...prices)
|
||||
const range = maxPrice - minPrice || 1
|
||||
const height = ((point.price - minPrice) / range) * 100
|
||||
const isLast = i === history.history.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
"flex-1 rounded-t transition-all hover:opacity-80",
|
||||
isLast ? "bg-accent" : "bg-accent/40"
|
||||
)}
|
||||
style={{ height: `${Math.max(height, 5)}%` }}
|
||||
title={`${point.date}: $${point.price.toFixed(2)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between mt-3 text-ui-sm text-foreground-subtle">
|
||||
<span>{history.history[0]?.date}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Info */}
|
||||
<div className="mb-10 sm:mb-12 animate-slide-up delay-200">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">TLD Information</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<Globe className="w-5 h-5 text-foreground-subtle" />
|
||||
<div>
|
||||
<p className="text-ui-sm text-foreground-subtle">Type</p>
|
||||
<p className="text-body-sm text-foreground capitalize">{details.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<Building className="w-5 h-5 text-foreground-subtle" />
|
||||
<div>
|
||||
<p className="text-ui-sm text-foreground-subtle">Registry</p>
|
||||
<p className="text-body-sm text-foreground">{details.registry}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<Calendar className="w-5 h-5 text-foreground-subtle" />
|
||||
<div>
|
||||
<p className="text-ui-sm text-foreground-subtle">Introduced</p>
|
||||
<p className="text-body-sm text-foreground">{details.introduced}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 p-4 bg-background-secondary/50 border border-border rounded-xl">
|
||||
<DollarSign className="w-5 h-5 text-foreground-subtle" />
|
||||
<div>
|
||||
<p className="text-ui-sm text-foreground-subtle">Registrars</p>
|
||||
<p className="text-body-sm text-foreground">{details.registrars.length} available</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registrar Comparison */}
|
||||
<div className="mb-10 sm:mb-12 animate-slide-up delay-250">
|
||||
<h2 className="text-body-lg font-medium text-foreground mb-4">Registrar Comparison</h2>
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary/50 border-b border-border">
|
||||
<th className="text-left text-ui-sm text-foreground-subtle font-medium px-4 py-3">Registrar</th>
|
||||
<th className="text-right text-ui-sm text-foreground-subtle font-medium px-4 py-3">Register</th>
|
||||
<th className="text-right text-ui-sm text-foreground-subtle font-medium px-4 py-3 hidden sm:table-cell">Renew</th>
|
||||
<th className="text-right text-ui-sm text-foreground-subtle font-medium px-4 py-3 hidden sm:table-cell">Transfer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{details.registrars.map((registrar, i) => (
|
||||
<tr key={registrar.name} className={clsx(
|
||||
"transition-colors",
|
||||
i === 0 ? "bg-accent/5" : "hover:bg-background-secondary/30"
|
||||
)}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-body-sm text-foreground">{registrar.name}</span>
|
||||
{i === 0 && (
|
||||
<span className="text-ui-xs text-accent bg-accent-muted px-1.5 py-0.5 rounded">
|
||||
Cheapest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={clsx(
|
||||
"text-body-sm font-medium",
|
||||
i === 0 ? "text-accent" : "text-foreground"
|
||||
)}>
|
||||
${registrar.registration_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden sm:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
${registrar.renewal_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right hidden sm:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
${registrar.transfer_price.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="p-6 sm:p-8 bg-background-secondary border border-border rounded-xl text-center animate-slide-up delay-300">
|
||||
<h3 className="text-body-lg font-medium text-foreground mb-2">
|
||||
Monitor .{details.tld} Domains
|
||||
</h3>
|
||||
<p className="text-body-sm text-foreground-muted mb-6">
|
||||
Track availability and get notified when your target domains become available.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-foreground text-background text-ui font-medium rounded-xl hover:bg-foreground/90 transition-all"
|
||||
>
|
||||
Start Monitoring
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
339
frontend/src/app/tld-pricing/page.tsx
Normal file
339
frontend/src/app/tld-pricing/page.tsx
Normal file
@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Lock,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
}
|
||||
|
||||
type SortField = 'tld' | 'avg_registration_price' | 'min_registration_price' | 'registrar_count'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// Mini sparkline chart component
|
||||
function MiniChart({ tld }: { tld: string }) {
|
||||
// Generate mock data for 12 months (in production, this would come from API)
|
||||
const data = useMemo(() => {
|
||||
const basePrice = 10 + Math.random() * 30
|
||||
return Array.from({ length: 12 }, (_, i) => {
|
||||
const variance = (Math.random() - 0.5) * 4
|
||||
return Math.max(1, basePrice + variance)
|
||||
})
|
||||
}, [tld])
|
||||
|
||||
const min = Math.min(...data)
|
||||
const max = Math.max(...data)
|
||||
const range = max - min || 1
|
||||
|
||||
const points = data.map((value, i) => {
|
||||
const x = (i / (data.length - 1)) * 100
|
||||
const y = 100 - ((value - min) / range) * 100
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
|
||||
const isIncreasing = data[data.length - 1] > data[0]
|
||||
|
||||
return (
|
||||
<svg className="w-24 h-8" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className={clsx(
|
||||
"transition-colors",
|
||||
isIncreasing ? "text-[#f97316]/60" : "text-accent/60"
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) {
|
||||
if (field !== currentField) {
|
||||
return <ChevronsUpDown className="w-4 h-4 text-foreground-subtle" />
|
||||
}
|
||||
return direction === 'asc'
|
||||
? <ChevronUp className="w-4 h-4 text-accent" />
|
||||
: <ChevronDown className="w-4 h-4 text-accent" />
|
||||
}
|
||||
|
||||
export default function TldPricingPage() {
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const [tlds, setTlds] = useState<TldData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sortField, setSortField] = useState<SortField>('tld')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
loadData()
|
||||
}, [checkAuth])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const overviewData = await api.getTldOverview(100)
|
||||
setTlds(overviewData?.tlds || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
setTlds([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedTlds = useMemo(() => {
|
||||
const sorted = [...tlds].sort((a, b) => {
|
||||
let aVal: number | string = a[sortField]
|
||||
let bVal: number | string = b[sortField]
|
||||
|
||||
if (typeof aVal === 'string') aVal = aVal.toLowerCase()
|
||||
if (typeof bVal === 'string') bVal = bVal.toLowerCase()
|
||||
|
||||
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
return sorted
|
||||
}, [tlds, sortField, sortDirection])
|
||||
|
||||
const getTrendIcon = (trend: string) => {
|
||||
switch (trend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="w-4 h-4 text-[#f97316]" />
|
||||
case 'down':
|
||||
return <TrendingDown className="w-4 h-4 text-accent" />
|
||||
default:
|
||||
return <Minus className="w-4 h-4 text-foreground-subtle" />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative">
|
||||
{/* Ambient glow */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6">
|
||||
<BarChart3 className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui-sm text-foreground-muted">TLD Price Intelligence</span>
|
||||
</div>
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
|
||||
Domain Extension Pricing
|
||||
</h1>
|
||||
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
|
||||
Track price trends across all major TLDs. Compare prices and monitor trends over time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Banner for non-authenticated users */}
|
||||
{!isAuthenticated && (
|
||||
<div className="mb-8 p-5 bg-accent-muted border border-accent/20 rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4 animate-fade-in">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/20 rounded-xl flex items-center justify-center">
|
||||
<Lock className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-body-sm font-medium text-foreground">Unlock Full TLD Data</p>
|
||||
<p className="text-ui-sm text-foreground-muted">
|
||||
Sign in to see detailed pricing and trends.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/register"
|
||||
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
>
|
||||
Get Started Free
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TLD Table */}
|
||||
<div className="bg-background-secondary/30 border border-border rounded-xl overflow-hidden animate-slide-up">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-background-secondary border-b border-border">
|
||||
<th className="text-left px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('tld')}
|
||||
className="flex items-center gap-2 text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
TLD
|
||||
<SortIcon field="tld" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">
|
||||
Description
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-left px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">
|
||||
12-Month Trend
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4">
|
||||
<button
|
||||
onClick={() => handleSort('avg_registration_price')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Avg. Price
|
||||
<SortIcon field="avg_registration_price" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-right px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('min_registration_price')}
|
||||
className="flex items-center gap-2 ml-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
From
|
||||
<SortIcon field="min_registration_price" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden xl:table-cell">
|
||||
<button
|
||||
onClick={() => handleSort('registrar_count')}
|
||||
className="flex items-center gap-2 mx-auto text-ui-sm text-foreground-subtle font-medium hover:text-foreground transition-colors"
|
||||
>
|
||||
Registrars
|
||||
<SortIcon field="registrar_count" currentField={sortField} direction={sortDirection} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-center px-4 sm:px-6 py-4 hidden sm:table-cell">
|
||||
<span className="text-ui-sm text-foreground-subtle font-medium">Trend</span>
|
||||
</th>
|
||||
<th className="px-4 sm:px-6 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sortedTlds.map((tld, idx) => (
|
||||
<tr
|
||||
key={tld.tld}
|
||||
className="hover:bg-background-secondary/50 transition-colors group"
|
||||
>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<span className="font-mono text-body-sm sm:text-body font-medium text-foreground">
|
||||
.{tld.tld}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden lg:table-cell">
|
||||
<span className="text-body-sm text-foreground-muted line-clamp-1">
|
||||
{tld.description}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 hidden md:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<MiniChart tld={tld.tld} />
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-ui-sm text-foreground-subtle">
|
||||
<Lock className="w-3 h-3" />
|
||||
Sign in
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm font-medium text-foreground">
|
||||
${tld.avg_registration_price.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-right hidden sm:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm text-accent">
|
||||
${tld.min_registration_price.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm text-foreground-subtle">•••</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden xl:table-cell">
|
||||
{isAuthenticated ? (
|
||||
<span className="text-body-sm text-foreground-muted">
|
||||
{tld.registrar_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-body-sm text-foreground-subtle">•</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 text-center hidden sm:table-cell">
|
||||
{isAuthenticated ? getTrendIcon(tld.trend) : <Minus className="w-4 h-4 text-foreground-subtle mx-auto" />}
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<Link
|
||||
href={isAuthenticated ? `/tld-pricing/${tld.tld}` : '/register'}
|
||||
className="flex items-center gap-1 text-ui-sm text-accent hover:text-accent-hover transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
Details
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-6 flex justify-center">
|
||||
<p className="text-ui-sm text-foreground-subtle">
|
||||
Showing {sortedTlds.length} TLDs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
287
frontend/src/components/DomainChecker.tsx
Normal file
287
frontend/src/components/DomainChecker.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Search, Check, X, Loader2, Calendar, Building2, Server, Plus, AlertTriangle, Clock } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { useStore } from '@/lib/store'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface CheckResult {
|
||||
domain: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
name_servers: string[] | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
export function DomainChecker() {
|
||||
const [domain, setDomain] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState<CheckResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { isAuthenticated } = useStore()
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!domain.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const res = await api.checkDomain(domain)
|
||||
setResult(res)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to check domain')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const getDaysUntilExpiration = (dateStr: string | null) => {
|
||||
if (!dateStr) return null
|
||||
const expDate = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffTime = expDate.getTime() - now.getTime()
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return diffDays
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-xl sm:max-w-2xl mx-auto px-4 sm:px-0">
|
||||
{/* Search Form */}
|
||||
<form onSubmit={handleCheck} className="relative">
|
||||
{/* Glow effect container */}
|
||||
<div className={clsx(
|
||||
"absolute -inset-px rounded-xl sm:rounded-2xl transition-opacity duration-700",
|
||||
isFocused ? "opacity-100" : "opacity-0"
|
||||
)}>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-accent/20 via-accent/10 to-accent/20 rounded-xl sm:rounded-2xl blur-xl" />
|
||||
</div>
|
||||
|
||||
{/* Input container */}
|
||||
<div className={clsx(
|
||||
"relative bg-background-secondary border rounded-xl sm:rounded-2xl transition-all duration-500",
|
||||
isFocused ? "border-border-hover" : "border-border"
|
||||
)}>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder="Enter any domain name..."
|
||||
className="w-full px-4 sm:px-6 py-4 sm:py-5 pr-28 sm:pr-36 bg-transparent rounded-xl sm:rounded-2xl
|
||||
text-body-sm sm:text-body-lg text-foreground placeholder:text-foreground-subtle
|
||||
focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !domain.trim()}
|
||||
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2
|
||||
px-4 sm:px-6 py-2.5 sm:py-3 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-lg sm:rounded-xl
|
||||
hover:bg-foreground/90 active:scale-[0.98]
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
transition-all duration-300 flex items-center gap-2 sm:gap-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Check</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 sm:mt-5 text-center text-ui-sm sm:text-ui text-foreground-subtle">
|
||||
Try <span className="text-foreground-muted">example.com</span>, <span className="text-foreground-muted">startup.io</span>, or <span className="text-foreground-muted">brand.co</span>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="mt-8 sm:mt-10 p-4 sm:p-5 bg-danger-muted border border-danger/20 rounded-xl sm:rounded-2xl animate-fade-in">
|
||||
<p className="text-danger text-body-xs sm:text-body-sm text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Card */}
|
||||
{result && (
|
||||
<div className="mt-8 sm:mt-10 animate-scale-in">
|
||||
{result.is_available ? (
|
||||
/* ========== AVAILABLE DOMAIN ========== */
|
||||
<div className="rounded-xl sm:rounded-2xl border border-accent/30 overflow-hidden text-left">
|
||||
{/* Header */}
|
||||
<div className="p-5 sm:p-6 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-accent/15 border border-accent/20 flex items-center justify-center shrink-0">
|
||||
<Check className="w-5 h-5 text-accent" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="font-mono text-body sm:text-body-lg font-medium text-foreground text-left">
|
||||
{result.domain}
|
||||
</p>
|
||||
<p className="text-body-sm text-accent text-left">
|
||||
Available for registration
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1.5 bg-accent text-background text-ui-sm font-medium rounded-lg shrink-0">
|
||||
Available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="p-4 sm:p-5 bg-background-secondary border-t border-accent/20">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<p className="text-body-sm text-foreground-muted text-left">
|
||||
Secure this domain or add it to your watchlist.
|
||||
</p>
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-5 py-2.5
|
||||
bg-accent text-background text-ui font-medium rounded-lg
|
||||
hover:bg-accent-hover transition-all duration-300"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add to Watchlist</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ========== TAKEN DOMAIN ========== */
|
||||
<div className="rounded-xl sm:rounded-2xl border border-border overflow-hidden bg-background-secondary text-left">
|
||||
{/* Header */}
|
||||
<div className="p-5 sm:p-6 border-b border-border">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-danger-muted border border-danger/20 flex items-center justify-center shrink-0">
|
||||
<X className="w-5 h-5 text-danger" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<p className="font-mono text-body sm:text-body-lg font-medium text-foreground text-left">
|
||||
{result.domain}
|
||||
</p>
|
||||
<p className="text-body-sm text-foreground-muted text-left">
|
||||
Currently registered
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-3 py-1.5 bg-background-tertiary text-foreground-muted text-ui-sm font-medium rounded-lg border border-border shrink-0">
|
||||
Taken
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Info */}
|
||||
{(result.registrar || result.expiration_date) && (
|
||||
<div className="p-5 sm:p-6 border-b border-border bg-background-tertiary/30">
|
||||
<div className="grid sm:grid-cols-2 gap-5">
|
||||
{result.registrar && (
|
||||
<div className="flex items-start gap-3 text-left">
|
||||
<div className="w-9 h-9 bg-background-tertiary rounded-lg flex items-center justify-center shrink-0">
|
||||
<Building2 className="w-4 h-4 text-foreground-subtle" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Registrar</p>
|
||||
<p className="text-body-sm text-foreground truncate text-left">{result.registrar}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.expiration_date && (
|
||||
<div className="flex items-start gap-3 text-left">
|
||||
<div className={clsx(
|
||||
"w-9 h-9 rounded-lg flex items-center justify-center shrink-0",
|
||||
getDaysUntilExpiration(result.expiration_date) !== null &&
|
||||
getDaysUntilExpiration(result.expiration_date)! <= 90
|
||||
? "bg-warning-muted"
|
||||
: "bg-background-tertiary"
|
||||
)}>
|
||||
<Calendar className={clsx(
|
||||
"w-4 h-4",
|
||||
getDaysUntilExpiration(result.expiration_date) !== null &&
|
||||
getDaysUntilExpiration(result.expiration_date)! <= 90
|
||||
? "text-warning"
|
||||
: "text-foreground-subtle"
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Expires</p>
|
||||
<p className="text-body-sm text-foreground text-left">
|
||||
{formatDate(result.expiration_date)}
|
||||
{getDaysUntilExpiration(result.expiration_date) !== null && (
|
||||
<span className={clsx(
|
||||
"ml-2 text-ui-sm",
|
||||
getDaysUntilExpiration(result.expiration_date)! <= 30
|
||||
? "text-danger"
|
||||
: getDaysUntilExpiration(result.expiration_date)! <= 90
|
||||
? "text-warning"
|
||||
: "text-foreground-subtle"
|
||||
)}>
|
||||
({getDaysUntilExpiration(result.expiration_date)} days)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.name_servers && result.name_servers.length > 0 && (
|
||||
<div className="flex items-start gap-3 sm:col-span-2 text-left">
|
||||
<div className="w-9 h-9 bg-background-tertiary rounded-lg flex items-center justify-center shrink-0">
|
||||
<Server className="w-4 h-4 text-foreground-subtle" />
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-ui-sm text-foreground-subtle uppercase tracking-wider mb-0.5 text-left">Name Servers</p>
|
||||
<p className="text-body-sm font-mono text-foreground-muted truncate text-left">
|
||||
{result.name_servers.slice(0, 2).join(' · ')}
|
||||
{result.name_servers.length > 2 && (
|
||||
<span className="text-foreground-subtle"> +{result.name_servers.length - 2}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watchlist CTA */}
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||
<div className="flex items-center gap-2 text-body-sm text-foreground-muted text-left">
|
||||
<Clock className="w-4 h-4 text-foreground-subtle shrink-0" />
|
||||
<span className="text-left">Get notified when this domain becomes available.</span>
|
||||
</div>
|
||||
<Link
|
||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
||||
className="shrink-0 flex items-center justify-center sm:justify-start gap-2 px-4 py-2.5
|
||||
bg-background-tertiary text-foreground text-ui font-medium rounded-lg
|
||||
border border-border hover:border-border-hover transition-all duration-300"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add to Watchlist</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
204
frontend/src/components/Header.tsx
Normal file
204
frontend/src/components/Header.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { LogOut, LayoutDashboard, Menu, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
// Logo Component - Text with accent dot
|
||||
function Logo() {
|
||||
return (
|
||||
<div className="relative inline-flex items-baseline">
|
||||
{/* Accent dot - top left */}
|
||||
<span className="absolute top-1 -left-3 sm:-left-3.5 flex items-center justify-center">
|
||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full bg-accent" />
|
||||
<span className="absolute w-2 h-2 sm:w-2.5 sm:h-2.5 rounded-full bg-accent animate-ping opacity-30" />
|
||||
</span>
|
||||
<span className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">
|
||||
pounce
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { isAuthenticated, user, logout } = useStore()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 sm:h-16 flex items-center justify-between">
|
||||
{/* Left side: Logo + Nav Links */}
|
||||
<div className="flex items-center gap-6 sm:gap-8">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="group hover:opacity-80 transition-opacity duration-300">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
{/* Left Nav Links (Desktop) */}
|
||||
<nav className="hidden sm:flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Domain
|
||||
</Link>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
TLD
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-3 py-1.5 text-ui text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Plans
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side: Auth Links */}
|
||||
<nav className="hidden sm:flex items-center gap-1">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 px-3 sm:px-4 py-1.5 sm:py-2 text-ui-sm sm:text-ui text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-lg
|
||||
transition-all duration-300"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 ml-2 pl-3 sm:pl-4 border-l border-border">
|
||||
<span className="text-ui-sm sm:text-ui text-foreground-subtle hidden md:inline">
|
||||
{user?.email}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-1.5 sm:p-2 text-foreground-subtle hover:text-foreground hover:bg-background-secondary
|
||||
rounded-lg transition-all duration-300"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="px-3 sm:px-4 py-1.5 sm:py-2 text-ui-sm sm:text-ui text-foreground-muted hover:text-foreground
|
||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="ml-1 sm:ml-2 text-ui-sm sm:text-ui px-4 sm:px-5 py-2 sm:py-2.5 bg-foreground text-background rounded-lg
|
||||
font-medium hover:bg-foreground/90 transition-all duration-300"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="sm:hidden p-2 text-foreground-muted hover:text-foreground transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
|
||||
<nav className="px-4 py-4 space-y-2">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Sign Out</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Domain
|
||||
</Link>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
TLD
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Plans
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
||||
transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="block px-4 py-3 text-body-sm text-center bg-foreground text-background
|
||||
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-300"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
272
frontend/src/lib/api.ts
Normal file
272
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,272 @@
|
||||
/**
|
||||
* API client for pounce backend
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1'
|
||||
|
||||
interface ApiError {
|
||||
detail: string
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token
|
||||
if (token) {
|
||||
localStorage.setItem('token', token)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('token')
|
||||
}
|
||||
return this.token
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>,
|
||||
}
|
||||
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(() => ({ detail: 'An error occurred' }))
|
||||
throw new Error(error.detail)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Auth
|
||||
async register(email: string, password: string, name?: string) {
|
||||
return this.request<{ id: number; email: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
})
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const response = await this.request<{ access_token: string; expires_in: number }>(
|
||||
'/auth/login',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
}
|
||||
)
|
||||
this.setToken(response.access_token)
|
||||
return response
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.setToken(null)
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request<{
|
||||
id: number
|
||||
email: string
|
||||
name: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}>('/auth/me')
|
||||
}
|
||||
|
||||
// Domain Check (public)
|
||||
async checkDomain(domain: string, quick = false) {
|
||||
return this.request<{
|
||||
domain: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
creation_date: string | null
|
||||
name_servers: string[] | null
|
||||
error_message: string | null
|
||||
checked_at: string
|
||||
}>('/check/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain, quick }),
|
||||
})
|
||||
}
|
||||
|
||||
// Domains (authenticated)
|
||||
async getDomains(page = 1, perPage = 20) {
|
||||
return this.request<{
|
||||
domains: Array<{
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
}>
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
}>(`/domains/?page=${page}&per_page=${perPage}`)
|
||||
}
|
||||
|
||||
async addDomain(name: string, notify = true) {
|
||||
return this.request<{
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
}>('/domains/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, notify_on_available: notify }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteDomain(id: number) {
|
||||
return this.request<void>(`/domains/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async refreshDomain(id: number) {
|
||||
return this.request<{
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
}>(`/domains/${id}/refresh`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Subscription
|
||||
async getSubscription() {
|
||||
return this.request<{
|
||||
id: number
|
||||
tier: string
|
||||
tier_name: string
|
||||
status: string
|
||||
domain_limit: number
|
||||
domains_used: number
|
||||
check_frequency: string
|
||||
history_days: number
|
||||
features: {
|
||||
email_alerts: boolean
|
||||
priority_alerts: boolean
|
||||
full_whois: boolean
|
||||
expiration_tracking: boolean
|
||||
api_access: boolean
|
||||
webhooks: boolean
|
||||
}
|
||||
started_at: string
|
||||
expires_at: string | null
|
||||
}>('/subscription/')
|
||||
}
|
||||
|
||||
async getTiers() {
|
||||
return this.request<{
|
||||
tiers: Array<{
|
||||
id: string
|
||||
name: string
|
||||
domain_limit: number
|
||||
price: number
|
||||
features: string[]
|
||||
}>
|
||||
}>('/subscription/tiers')
|
||||
}
|
||||
|
||||
// Domain History (Professional/Enterprise)
|
||||
async getDomainHistory(domainId: number, limit = 30) {
|
||||
return this.request<{
|
||||
domain: string
|
||||
total_checks: number
|
||||
history: Array<{
|
||||
id: number
|
||||
status: string
|
||||
is_available: boolean
|
||||
checked_at: string
|
||||
}>
|
||||
}>(`/domains/${domainId}/history?limit=${limit}`)
|
||||
}
|
||||
|
||||
// TLD Pricing
|
||||
async getTldOverview(limit = 20) {
|
||||
return this.request<{
|
||||
tlds: Array<{
|
||||
tld: string
|
||||
type: string
|
||||
description: string
|
||||
avg_registration_price: number
|
||||
min_registration_price: number
|
||||
max_registration_price: number
|
||||
registrar_count: number
|
||||
trend: string
|
||||
}>
|
||||
total: number
|
||||
}>(`/tld-prices/overview?limit=${limit}`)
|
||||
}
|
||||
|
||||
async getTldHistory(tld: string, days = 90) {
|
||||
return this.request<{
|
||||
tld: string
|
||||
current_price: number
|
||||
price_change_7d: number
|
||||
price_change_30d: number
|
||||
price_change_90d: number
|
||||
history: Array<{
|
||||
date: string
|
||||
price: number
|
||||
}>
|
||||
}>(`/tld-prices/${tld}/history?days=${days}`)
|
||||
}
|
||||
|
||||
async getTldCompare(tld: string) {
|
||||
return this.request<{
|
||||
tld: string
|
||||
registrars: Array<{
|
||||
name: string
|
||||
registration_price: number
|
||||
renewal_price: number
|
||||
transfer_price: number
|
||||
features: string[]
|
||||
}>
|
||||
}>(`/tld-prices/${tld}/compare`)
|
||||
}
|
||||
|
||||
async getTrendingTlds() {
|
||||
return this.request<{
|
||||
trending: Array<{
|
||||
tld: string
|
||||
reason: string
|
||||
price_change: number
|
||||
current_price: number
|
||||
}>
|
||||
}>('/tld-prices/trending')
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient()
|
||||
|
||||
178
frontend/src/lib/store.ts
Normal file
178
frontend/src/lib/store.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Global state management with Zustand
|
||||
*/
|
||||
import { create } from 'zustand'
|
||||
import { api } from './api'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
id: number
|
||||
name: string
|
||||
status: string
|
||||
is_available: boolean
|
||||
registrar: string | null
|
||||
expiration_date: string | null
|
||||
notify_on_available: boolean
|
||||
created_at: string
|
||||
last_checked: string | null
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
tier: string
|
||||
tier_name?: string
|
||||
domain_limit: number
|
||||
domains_used: number
|
||||
check_frequency?: string
|
||||
history_days?: number
|
||||
features?: {
|
||||
email_alerts: boolean
|
||||
priority_alerts: boolean
|
||||
full_whois: boolean
|
||||
expiration_tracking: boolean
|
||||
api_access: boolean
|
||||
webhooks: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
|
||||
// Domains
|
||||
domains: Domain[]
|
||||
domainsTotal: number
|
||||
domainsPage: number
|
||||
|
||||
// Subscription
|
||||
subscription: Subscription | null
|
||||
|
||||
// Actions
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
register: (email: string, password: string, name?: string) => Promise<void>
|
||||
logout: () => void
|
||||
checkAuth: () => Promise<void>
|
||||
|
||||
fetchDomains: (page?: number) => Promise<void>
|
||||
addDomain: (name: string) => Promise<void>
|
||||
deleteDomain: (id: number) => Promise<void>
|
||||
refreshDomain: (id: number) => Promise<void>
|
||||
|
||||
fetchSubscription: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
// Initial state
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
domains: [],
|
||||
domainsTotal: 0,
|
||||
domainsPage: 1,
|
||||
subscription: null,
|
||||
|
||||
// Auth actions
|
||||
login: async (email, password) => {
|
||||
await api.login(email, password)
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
|
||||
// Fetch user data
|
||||
await get().fetchDomains()
|
||||
await get().fetchSubscription()
|
||||
},
|
||||
|
||||
register: async (email, password, name) => {
|
||||
await api.register(email, password, name)
|
||||
// Auto-login after registration
|
||||
await get().login(email, password)
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
api.logout()
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
domains: [],
|
||||
subscription: null,
|
||||
})
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
if (api.getToken()) {
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
await get().fetchDomains()
|
||||
await get().fetchSubscription()
|
||||
}
|
||||
} catch {
|
||||
api.logout()
|
||||
set({ user: null, isAuthenticated: false })
|
||||
} finally {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
// Domain actions
|
||||
fetchDomains: async (page = 1) => {
|
||||
try {
|
||||
const response = await api.getDomains(page)
|
||||
set({
|
||||
domains: response.domains,
|
||||
domainsTotal: response.total,
|
||||
domainsPage: response.page,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch domains:', error)
|
||||
}
|
||||
},
|
||||
|
||||
addDomain: async (name) => {
|
||||
await api.addDomain(name)
|
||||
await get().fetchDomains(get().domainsPage)
|
||||
await get().fetchSubscription()
|
||||
},
|
||||
|
||||
deleteDomain: async (id) => {
|
||||
await api.deleteDomain(id)
|
||||
await get().fetchDomains(get().domainsPage)
|
||||
await get().fetchSubscription()
|
||||
},
|
||||
|
||||
refreshDomain: async (id) => {
|
||||
const updated = await api.refreshDomain(id)
|
||||
const domains = get().domains.map((d) =>
|
||||
d.id === id ? { ...d, ...updated } : d
|
||||
)
|
||||
set({ domains })
|
||||
},
|
||||
|
||||
// Subscription actions
|
||||
fetchSubscription: async () => {
|
||||
try {
|
||||
const sub = await api.getSubscription()
|
||||
set({
|
||||
subscription: {
|
||||
tier: sub.tier,
|
||||
tier_name: sub.tier_name,
|
||||
domain_limit: sub.domain_limit,
|
||||
domains_used: sub.domains_used,
|
||||
check_frequency: sub.check_frequency,
|
||||
history_days: sub.history_days,
|
||||
features: sub.features,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// User might not have subscription
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
127
frontend/tailwind.config.ts
Normal file
127
frontend/tailwind.config.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: {
|
||||
DEFAULT: '#08080a',
|
||||
secondary: '#0c0c0f',
|
||||
tertiary: '#121215',
|
||||
elevated: '#18181c',
|
||||
},
|
||||
foreground: {
|
||||
DEFAULT: '#fafaf9',
|
||||
muted: '#a1a1a1',
|
||||
subtle: '#6a6a6a',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#10b981',
|
||||
hover: '#059669',
|
||||
muted: '#10b98112',
|
||||
soft: '#10b98108',
|
||||
},
|
||||
warning: {
|
||||
DEFAULT: '#f59e0b',
|
||||
muted: '#f59e0b15',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#ef4444',
|
||||
muted: '#ef444412',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: '#1a1a1f',
|
||||
hover: '#26262d',
|
||||
subtle: '#141418',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||
mono: ['var(--font-mono)', 'JetBrains Mono', 'monospace'],
|
||||
display: ['var(--font-display)', 'Georgia', 'serif'],
|
||||
},
|
||||
fontSize: {
|
||||
// ===== DISPLAY SIZES (Headlines) =====
|
||||
// Hero - Main landing page (mobile → tablet → desktop → large)
|
||||
'hero-sm': ['2.5rem', { lineHeight: '1.05', letterSpacing: '-0.03em' }], // 40px
|
||||
'hero': ['3.5rem', { lineHeight: '1.05', letterSpacing: '-0.035em' }], // 56px
|
||||
'hero-md': ['5rem', { lineHeight: '1.02', letterSpacing: '-0.04em' }], // 80px
|
||||
'hero-lg': ['6.5rem', { lineHeight: '1.0', letterSpacing: '-0.045em' }], // 104px
|
||||
'hero-xl': ['8rem', { lineHeight: '0.95', letterSpacing: '-0.05em' }], // 128px
|
||||
|
||||
// Display - Page titles, section heads
|
||||
'display-sm': ['1.5rem', { lineHeight: '1.15', letterSpacing: '-0.02em' }], // 24px
|
||||
'display': ['2rem', { lineHeight: '1.12', letterSpacing: '-0.025em' }], // 32px
|
||||
'display-md': ['2.5rem', { lineHeight: '1.1', letterSpacing: '-0.03em' }], // 40px
|
||||
'display-lg': ['3.25rem', { lineHeight: '1.08', letterSpacing: '-0.035em' }], // 52px
|
||||
'display-xl': ['4.5rem', { lineHeight: '1.05', letterSpacing: '-0.04em' }], // 72px
|
||||
|
||||
// Heading - Cards, smaller sections
|
||||
'heading-sm': ['1.125rem', { lineHeight: '1.3', letterSpacing: '-0.01em' }], // 18px
|
||||
'heading': ['1.25rem', { lineHeight: '1.25', letterSpacing: '-0.015em' }], // 20px
|
||||
'heading-md': ['1.5rem', { lineHeight: '1.2', letterSpacing: '-0.02em' }], // 24px
|
||||
'heading-lg': ['1.875rem', { lineHeight: '1.15', letterSpacing: '-0.02em' }], // 30px
|
||||
|
||||
// ===== BODY SIZES =====
|
||||
'body-xs': ['0.8125rem', { lineHeight: '1.6' }], // 13px
|
||||
'body-sm': ['0.875rem', { lineHeight: '1.65' }], // 14px
|
||||
'body': ['1rem', { lineHeight: '1.7' }], // 16px
|
||||
'body-md': ['1.0625rem', { lineHeight: '1.7' }], // 17px
|
||||
'body-lg': ['1.125rem', { lineHeight: '1.75' }], // 18px
|
||||
'body-xl': ['1.25rem', { lineHeight: '1.8' }], // 20px
|
||||
|
||||
// ===== UI SIZES =====
|
||||
'ui-xs': ['0.6875rem', { lineHeight: '1.4', letterSpacing: '0.02em' }], // 11px
|
||||
'ui-sm': ['0.75rem', { lineHeight: '1.45', letterSpacing: '0.015em' }], // 12px
|
||||
'ui': ['0.8125rem', { lineHeight: '1.5', letterSpacing: '0.01em' }], // 13px
|
||||
'ui-md': ['0.875rem', { lineHeight: '1.5' }], // 14px
|
||||
'ui-lg': ['0.9375rem', { lineHeight: '1.5' }], // 15px
|
||||
|
||||
// Label - Uppercase labels
|
||||
'label': ['0.6875rem', { lineHeight: '1.3', letterSpacing: '0.1em', fontWeight: '500' }], // 11px
|
||||
'label-md': ['0.75rem', { lineHeight: '1.35', letterSpacing: '0.08em', fontWeight: '500' }], // 12px
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
'26': '6.5rem',
|
||||
'30': '7.5rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'slide-up': 'slideUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'scale-in': 'scaleIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||||
'glow-pulse': 'glowPulse 3s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(24px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
glowPulse: {
|
||||
'0%, 100%': { opacity: '0.4' },
|
||||
'50%': { opacity: '0.7' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user