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:
2025-12-08 07:26:57 +01:00
commit 9acb90c067
56 changed files with 13080 additions and 0 deletions

42
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View File

View 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
View 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
View 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
View 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
View 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
View 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
]
}

View 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"],
}

View 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
View 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
View 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
View 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"}

View 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"]

View 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}>"

View 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}>"

View 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}>"

View 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
View 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()

View 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",
]

View 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

View 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

View 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

View File

@ -0,0 +1,6 @@
"""Services."""
from app.services.domain_checker import DomainChecker
from app.services.auth import AuthService
__all__ = ["DomainChecker", "AuthService"]

View 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()

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

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

8
frontend/next.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

31
frontend/package.json Normal file
View 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"
}
}

View File

@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>
)
}

View 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; }

View 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>
)
}

View 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&apos;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
View 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&apos;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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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"]
}