Compare commits
2 Commits
master
...
fcabe882cd
| Author | SHA1 | Date | |
|---|---|---|---|
| fcabe882cd | |||
| 29178a75a5 |
13
.env.example
13
.env.example
@@ -73,6 +73,19 @@ ADMIN_PASSWORD=changeme123
|
||||
# Token secret for session management (auto-generated if not set)
|
||||
# ADMIN_TOKEN_SECRET=
|
||||
|
||||
# ============== User Authentication ==============
|
||||
# JWT secret key (auto-generated if not set)
|
||||
# JWT_SECRET_KEY=
|
||||
|
||||
# Frontend URL for redirects
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ============== Stripe Payments ==============
|
||||
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# ============== Monitoring ==============
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
75
.env.production
Normal file
75
.env.production
Normal file
@@ -0,0 +1,75 @@
|
||||
# ============================================
|
||||
# Document Translation API - Production Environment
|
||||
# ============================================
|
||||
# IMPORTANT: Review and update all values before deployment
|
||||
|
||||
# ===========================================
|
||||
# Application Settings
|
||||
# ===========================================
|
||||
APP_NAME=Document Translation API
|
||||
APP_ENV=production
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ===========================================
|
||||
# Server Configuration
|
||||
# ===========================================
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ===========================================
|
||||
# Domain Configuration
|
||||
# ===========================================
|
||||
DOMAIN=translate.yourdomain.com
|
||||
NEXT_PUBLIC_API_URL=https://translate.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# Translation Service Configuration
|
||||
# ===========================================
|
||||
TRANSLATION_SERVICE=ollama
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
|
||||
# DeepL API (optional)
|
||||
DEEPL_API_KEY=
|
||||
|
||||
# OpenAI API (optional)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# ===========================================
|
||||
# File Upload Settings
|
||||
# ===========================================
|
||||
MAX_FILE_SIZE_MB=50
|
||||
ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx
|
||||
|
||||
# ===========================================
|
||||
# Rate Limiting
|
||||
# ===========================================
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||
|
||||
# ===========================================
|
||||
# Security (CHANGE THESE!)
|
||||
# ===========================================
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CHANGE_THIS_SECURE_PASSWORD
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=https://translate.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# Monitoring (Optional)
|
||||
# ===========================================
|
||||
GRAFANA_USER=admin
|
||||
GRAFANA_PASSWORD=CHANGE_THIS_TOO
|
||||
|
||||
# ===========================================
|
||||
# SSL Configuration
|
||||
# ===========================================
|
||||
LETSENCRYPT_EMAIL=admin@yourdomain.com
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,9 +40,11 @@ outputs/
|
||||
temp/
|
||||
translated_files/
|
||||
translated_test.*
|
||||
data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# UV / UV lock
|
||||
.venv/
|
||||
|
||||
455
DEPLOYMENT_GUIDE.md
Normal file
455
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,455 @@
|
||||
# 🚀 Document Translation API - Production Deployment Guide
|
||||
|
||||
Complete guide for deploying the Document Translation API in production environments.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Architecture Overview](#architecture-overview)
|
||||
3. [Docker Deployment](#docker-deployment)
|
||||
4. [Kubernetes Deployment](#kubernetes-deployment)
|
||||
5. [SSL/TLS Configuration](#ssltls-configuration)
|
||||
6. [Environment Configuration](#environment-configuration)
|
||||
7. [Monitoring & Logging](#monitoring--logging)
|
||||
8. [Scaling & Performance](#scaling--performance)
|
||||
9. [Backup & Recovery](#backup--recovery)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose v2.0+
|
||||
- Domain name (for production)
|
||||
- SSL certificate (or use Let's Encrypt)
|
||||
|
||||
### One-Command Deployment
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/your-repo/translate-api.git
|
||||
cd translate-api
|
||||
git checkout production-deployment
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env.production
|
||||
# Edit .env.production with your settings
|
||||
|
||||
# Deploy!
|
||||
./scripts/deploy.sh production
|
||||
```
|
||||
|
||||
Your application will be available at `https://your-domain.com`
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Internet │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Nginx │
|
||||
│ (Reverse Proxy)│
|
||||
│ - SSL/TLS │
|
||||
│ - Rate Limit │
|
||||
│ - Caching │
|
||||
└────────┬────────┘
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────────▼────┐ ┌──────▼─────┐ ┌─────▼─────┐
|
||||
│ Frontend │ │ Backend │ │ Admin │
|
||||
│ (Next.js) │ │ (FastAPI) │ │ Dashboard│
|
||||
│ Port 3000 │ │ Port 8000 │ │ │
|
||||
└─────────────┘ └──────┬─────┘ └───────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────┐
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌──────────▼──────────┐ ┌───────▼───────┐
|
||||
│ Ollama │ │ Google/DeepL/ │ │ Redis │
|
||||
│ (Local LLM) │ │ OpenAI APIs │ │ (Cache) │
|
||||
└─────────────────┘ └─────────────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
translate-api/
|
||||
├── docker/
|
||||
│ ├── backend/
|
||||
│ │ └── Dockerfile
|
||||
│ ├── frontend/
|
||||
│ │ └── Dockerfile
|
||||
│ ├── nginx/
|
||||
│ │ ├── nginx.conf
|
||||
│ │ ├── conf.d/
|
||||
│ │ │ └── default.conf
|
||||
│ │ └── ssl/
|
||||
│ │ ├── fullchain.pem
|
||||
│ │ └── privkey.pem
|
||||
│ └── prometheus/
|
||||
│ └── prometheus.yml
|
||||
├── scripts/
|
||||
│ ├── deploy.sh
|
||||
│ ├── backup.sh
|
||||
│ ├── health-check.sh
|
||||
│ └── setup-ssl.sh
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.dev.yml
|
||||
├── .env.production
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
### Basic Deployment
|
||||
|
||||
```bash
|
||||
# Production (with nginx, SSL)
|
||||
docker compose --env-file .env.production up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### With Optional Services
|
||||
|
||||
```bash
|
||||
# With local Ollama LLM
|
||||
docker compose --profile with-ollama up -d
|
||||
|
||||
# With Redis caching
|
||||
docker compose --profile with-cache up -d
|
||||
|
||||
# With monitoring (Prometheus + Grafana)
|
||||
docker compose --profile with-monitoring up -d
|
||||
|
||||
# All services
|
||||
docker compose --profile with-ollama --profile with-cache --profile with-monitoring up -d
|
||||
```
|
||||
|
||||
### Service Endpoints
|
||||
|
||||
| Service | Internal Port | External Access |
|
||||
|---------|--------------|-----------------|
|
||||
| Frontend | 3000 | https://domain.com/ |
|
||||
| Backend API | 8000 | https://domain.com/api/ |
|
||||
| Admin Dashboard | 8000 | https://domain.com/admin |
|
||||
| Health Check | 8000 | https://domain.com/health |
|
||||
| Prometheus | 9090 | Internal only |
|
||||
| Grafana | 3001 | https://domain.com:3001 |
|
||||
|
||||
---
|
||||
|
||||
## ☸️ Kubernetes Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster (1.25+)
|
||||
- kubectl configured
|
||||
- Ingress controller (nginx-ingress)
|
||||
- cert-manager (for SSL)
|
||||
|
||||
### Deploy to Kubernetes
|
||||
|
||||
```bash
|
||||
# Create namespace and deploy
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
|
||||
# Check status
|
||||
kubectl get pods -n translate-api
|
||||
kubectl get services -n translate-api
|
||||
kubectl get ingress -n translate-api
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/backend -n translate-api
|
||||
```
|
||||
|
||||
### Scaling
|
||||
|
||||
```bash
|
||||
# Manual scaling
|
||||
kubectl scale deployment/backend --replicas=5 -n translate-api
|
||||
|
||||
# Auto-scaling is configured via HPA
|
||||
kubectl get hpa -n translate-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 SSL/TLS Configuration
|
||||
|
||||
### Option 1: Let's Encrypt (Recommended)
|
||||
|
||||
```bash
|
||||
# Automated setup
|
||||
./scripts/setup-ssl.sh translate.yourdomain.com admin@yourdomain.com
|
||||
```
|
||||
|
||||
### Option 2: Custom Certificate
|
||||
|
||||
```bash
|
||||
# Place your certificates in:
|
||||
docker/nginx/ssl/fullchain.pem # Full certificate chain
|
||||
docker/nginx/ssl/privkey.pem # Private key
|
||||
docker/nginx/ssl/chain.pem # CA chain (optional)
|
||||
```
|
||||
|
||||
### Option 3: Self-Signed (Development Only)
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Configuration
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DOMAIN` | Your domain name | - |
|
||||
| `ADMIN_USERNAME` | Admin login | admin |
|
||||
| `ADMIN_PASSWORD` | Admin password | changeme123 |
|
||||
| `TRANSLATION_SERVICE` | Default provider | ollama |
|
||||
|
||||
### Translation Providers
|
||||
|
||||
```bash
|
||||
# Ollama (Local LLM)
|
||||
TRANSLATION_SERVICE=ollama
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
|
||||
# Google Translate (Free)
|
||||
TRANSLATION_SERVICE=google
|
||||
|
||||
# DeepL (Requires API key)
|
||||
TRANSLATION_SERVICE=deepl
|
||||
DEEPL_API_KEY=your-api-key
|
||||
|
||||
# OpenAI (Requires API key)
|
||||
TRANSLATION_SERVICE=openai
|
||||
OPENAI_API_KEY=your-api-key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```bash
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring & Logging
|
||||
|
||||
### Enable Monitoring
|
||||
|
||||
```bash
|
||||
docker compose --profile with-monitoring up -d
|
||||
```
|
||||
|
||||
### Access Dashboards
|
||||
|
||||
- **Prometheus**: http://localhost:9090
|
||||
- **Grafana**: http://localhost:3001 (admin/admin)
|
||||
|
||||
### Log Locations
|
||||
|
||||
```bash
|
||||
# Docker logs
|
||||
docker compose logs backend
|
||||
docker compose logs frontend
|
||||
docker compose logs nginx
|
||||
|
||||
# Application logs (inside container)
|
||||
docker exec translate-backend cat /app/logs/app.log
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Manual check
|
||||
./scripts/health-check.sh --verbose
|
||||
|
||||
# API health endpoint
|
||||
curl https://your-domain.com/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Scaling & Performance
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
replicas: 4
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
```bash
|
||||
# Backend workers (in Dockerfile CMD)
|
||||
CMD ["uvicorn", "main:app", "--workers", "4"]
|
||||
|
||||
# Nginx connections
|
||||
worker_connections 2048;
|
||||
|
||||
# File upload limits
|
||||
client_max_body_size 100M;
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '1'
|
||||
reservations:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Backup & Recovery
|
||||
|
||||
### Automated Backup
|
||||
|
||||
```bash
|
||||
# Run backup
|
||||
./scripts/backup.sh /path/to/backups
|
||||
|
||||
# Add to crontab (daily at 2 AM)
|
||||
0 2 * * * /path/to/scripts/backup.sh /backups
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Extract backup
|
||||
tar xzf translate_backup_20241130.tar.gz
|
||||
|
||||
# Restore files
|
||||
docker cp translate_backup/uploads translate-backend:/app/
|
||||
docker cp translate_backup/outputs translate-backend:/app/
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Find process
|
||||
netstat -tulpn | grep :8000
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
#### Container Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs backend --tail 100
|
||||
|
||||
# Check resources
|
||||
docker stats
|
||||
```
|
||||
|
||||
#### SSL Certificate Issues
|
||||
```bash
|
||||
# Test certificate
|
||||
openssl s_client -connect your-domain.com:443
|
||||
|
||||
# Renew Let's Encrypt
|
||||
./scripts/renew-ssl.sh
|
||||
```
|
||||
|
||||
#### Memory Issues
|
||||
```bash
|
||||
# Increase limits in docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. Check logs: `docker compose logs -f`
|
||||
2. Run health check: `./scripts/health-check.sh`
|
||||
3. Review configuration: `.env.production`
|
||||
4. Check container status: `docker compose ps`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maintenance Commands
|
||||
|
||||
```bash
|
||||
# Update application
|
||||
git pull origin production-deployment
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Prune unused resources
|
||||
docker system prune -a
|
||||
|
||||
# View resource usage
|
||||
docker stats
|
||||
|
||||
# Enter container shell
|
||||
docker exec -it translate-backend /bin/bash
|
||||
|
||||
# Database backup (if using)
|
||||
docker exec translate-db pg_dump -U user database > backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Checklist
|
||||
|
||||
- [ ] Change default admin password
|
||||
- [ ] Configure SSL/TLS
|
||||
- [ ] Set up firewall rules
|
||||
- [ ] Enable rate limiting
|
||||
- [ ] Configure CORS properly
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Monitor access logs
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues and feature requests, please open a GitHub issue or contact support.
|
||||
|
||||
**Version**: 2.0.0
|
||||
**Last Updated**: November 2025
|
||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Document Translation API - Development Docker Compose
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
target: builder # Use builder stage for dev
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/venv # Don't override venv
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- LOG_LEVEL=DEBUG
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
target: builder
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
# No nginx in dev - direct access to services
|
||||
nginx:
|
||||
profiles:
|
||||
- disabled
|
||||
208
docker-compose.yml
Normal file
208
docker-compose.yml
Normal file
@@ -0,0 +1,208 @@
|
||||
# Document Translation API - Production Docker Compose
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# Backend API Service
|
||||
# ===========================================
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
container_name: translate-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama}
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
|
||||
- DEEPL_API_KEY=${DEEPL_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
|
||||
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme123}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- outputs_data:/app/outputs
|
||||
- logs_data:/app/logs
|
||||
networks:
|
||||
- translate-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
# ===========================================
|
||||
# Frontend Web Service
|
||||
# ===========================================
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
container_name: translate-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Nginx Reverse Proxy
|
||||
# ===========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: translate-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ===========================================
|
||||
# Ollama (Optional - Local LLM)
|
||||
# ===========================================
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: translate-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
networks:
|
||||
- translate-network
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
profiles:
|
||||
- with-ollama
|
||||
|
||||
# ===========================================
|
||||
# Redis (Optional - For caching & sessions)
|
||||
# ===========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: translate-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- translate-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
profiles:
|
||||
- with-cache
|
||||
|
||||
# ===========================================
|
||||
# Prometheus (Optional - Monitoring)
|
||||
# ===========================================
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: translate-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
networks:
|
||||
- translate-network
|
||||
profiles:
|
||||
- with-monitoring
|
||||
|
||||
# ===========================================
|
||||
# Grafana (Optional - Dashboards)
|
||||
# ===========================================
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: translate-grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./docker/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
profiles:
|
||||
- with-monitoring
|
||||
|
||||
# ===========================================
|
||||
# Networks
|
||||
# ===========================================
|
||||
networks:
|
||||
translate-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
# ===========================================
|
||||
# Volumes
|
||||
# ===========================================
|
||||
volumes:
|
||||
uploads_data:
|
||||
driver: local
|
||||
outputs_data:
|
||||
driver: local
|
||||
logs_data:
|
||||
driver: local
|
||||
nginx_cache:
|
||||
driver: local
|
||||
ollama_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
65
docker/backend/Dockerfile
Normal file
65
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# Document Translation API - Backend Dockerfile
|
||||
# Multi-stage build for optimized production image
|
||||
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libmagic1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r translator && useradd -r -g translator translator
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \
|
||||
&& chown -R translator:translator /app
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=translator:translator . .
|
||||
|
||||
# Switch to non-root user
|
||||
USER translator
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PORT=8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
51
docker/frontend/Dockerfile
Normal file
51
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Document Translation Frontend - Dockerfile
|
||||
# Multi-stage build for optimized production
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production=false
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build arguments for environment
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
150
docker/nginx/conf.d/default.conf
Normal file
150
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,150 @@
|
||||
# Document Translation API - Main Server Block
|
||||
# HTTP to HTTPS redirect and main application routing
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Allow health checks on HTTP
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# ACME challenge for Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server - main application
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL certificates (replace with your paths)
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Modern SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# OCSP Stapling
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
||||
|
||||
# API routes - proxy to backend
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
|
||||
proxy_pass http://backend;
|
||||
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;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# CORS headers for API
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# File upload endpoint - special handling
|
||||
location /translate {
|
||||
limit_req zone=upload_limit burst=5 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
|
||||
proxy_pass http://backend/translate;
|
||||
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;
|
||||
|
||||
# Increased timeouts for file processing
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Admin endpoints
|
||||
location /admin {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://backend/admin;
|
||||
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;
|
||||
}
|
||||
|
||||
# Frontend - Next.js application
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
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;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Static files caching
|
||||
location /_next/static/ {
|
||||
proxy_pass http://frontend;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
74
docker/nginx/nginx.conf
Normal file
74
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,74 @@
|
||||
# Nginx Configuration for Document Translation API
|
||||
# Production-ready with SSL, caching, and security headers
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance optimizations
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/xml application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# File upload size (for document translation)
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
client_header_timeout 60s;
|
||||
|
||||
# Proxy settings
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Upstream definitions
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Include additional configs
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
8
docker/nginx/ssl/.gitkeep
Normal file
8
docker/nginx/ssl/.gitkeep
Normal file
@@ -0,0 +1,8 @@
|
||||
# Self-signed SSL certificate placeholder
|
||||
# Replace with real certificates in production!
|
||||
|
||||
# Generate self-signed certificate:
|
||||
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
# -keyout privkey.pem \
|
||||
# -out fullchain.pem \
|
||||
# -subj "/CN=localhost"
|
||||
37
docker/prometheus/prometheus.yml
Normal file
37
docker/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Prometheus Configuration for Document Translation API
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: 'translate-api'
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
|
||||
rule_files: []
|
||||
|
||||
scrape_configs:
|
||||
# Backend API metrics
|
||||
- job_name: 'translate-backend'
|
||||
static_configs:
|
||||
- targets: ['backend:8000']
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 10s
|
||||
|
||||
# Nginx metrics (requires nginx-prometheus-exporter)
|
||||
- job_name: 'nginx'
|
||||
static_configs:
|
||||
- targets: ['nginx-exporter:9113']
|
||||
|
||||
# Node exporter for system metrics
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
# Docker metrics
|
||||
- job_name: 'docker'
|
||||
static_configs:
|
||||
- targets: ['cadvisor:8080']
|
||||
169
frontend/src/app/auth/login/page.tsx
Normal file
169
frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || "Login failed");
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem("token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
// Redirect
|
||||
router.push(redirect);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-white">Translate Co.</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Welcome back</h1>
|
||||
<p className="text-zinc-400">Sign in to continue translating</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-zinc-300">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||
<Link href="/auth/forgot-password" className="text-sm text-teal-400 hover:text-teal-300">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-zinc-400">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||
className="text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Sign up for free
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features reminder */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-zinc-500 mb-3">Start with our free plan:</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{["3 docs/day", "10 pages/doc", "Ollama support"].map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className="px-3 py-1 rounded-full bg-zinc-800 text-zinc-400 text-xs"
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
frontend/src/app/auth/register/page.tsx
Normal file
206
frontend/src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem("token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
// Redirect
|
||||
router.push(redirect);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-white">Translate Co.</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Create an account</h1>
|
||||
<p className="text-zinc-400">Start translating documents for free</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-zinc-300">Full Name</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-zinc-300">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-zinc-300">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Create Account
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-zinc-400">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||
className="text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-zinc-500">
|
||||
By creating an account, you agree to our{" "}
|
||||
<Link href="/terms" className="text-zinc-400 hover:text-zinc-300">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="text-zinc-400 hover:text-zinc-300">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
frontend/src/app/dashboard/page.tsx
Normal file
363
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FileText,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
subscription_status: string;
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
extra_credits: number;
|
||||
plan_limits: {
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
features: string[];
|
||||
providers: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
docs_used: number;
|
||||
docs_limit: number;
|
||||
docs_remaining: number;
|
||||
pages_used: number;
|
||||
extra_credits: number;
|
||||
max_pages_per_doc: number;
|
||||
allowed_providers: string[];
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [usage, setUsage] = useState<UsageStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
router.push("/auth/login?redirect=/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUserData(token);
|
||||
}, [router]);
|
||||
|
||||
const fetchUserData = async (token: string) => {
|
||||
try {
|
||||
const [userRes, usageRes] = await Promise.all([
|
||||
fetch("http://localhost:8000/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch("http://localhost:8000/api/auth/usage", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const userData = await userRes.json();
|
||||
const usageData = await usageRes.json();
|
||||
|
||||
setUser(userData);
|
||||
setUsage(usageData);
|
||||
} catch (error) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
router.push("/auth/login?redirect=/dashboard");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
router.push("/pricing");
|
||||
};
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/billing-portal", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.open(data.url, "_blank");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open billing portal");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#262626] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-teal-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docsPercentage = usage.docs_limit > 0
|
||||
? Math.min(100, (usage.docs_used / usage.docs_limit) * 100)
|
||||
: 0;
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: "bg-zinc-500",
|
||||
starter: "bg-blue-500",
|
||||
pro: "bg-teal-500",
|
||||
business: "bg-purple-500",
|
||||
enterprise: "bg-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626]">
|
||||
{/* Header */}
|
||||
<header className="border-b border-zinc-800 bg-[#1a1a1a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">Translate Co.</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Translate
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-teal-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white">Welcome back, {user.name.split(" ")[0]}!</h1>
|
||||
<p className="text-zinc-400 mt-1">Here's an overview of your translation usage</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{/* Current Plan */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-zinc-400">Current Plan</span>
|
||||
<Badge className={cn("text-white", planColors[user.plan])}>
|
||||
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="h-5 w-5 text-amber-400" />
|
||||
<span className="text-2xl font-bold text-white capitalize">{user.plan}</span>
|
||||
</div>
|
||||
{user.plan !== "enterprise" && (
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
size="sm"
|
||||
className="mt-4 w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
Upgrade Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documents Used */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-zinc-400">Documents This Month</span>
|
||||
<FileText className="h-4 w-4 text-zinc-500" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white mb-2">
|
||||
{usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit}
|
||||
</div>
|
||||
<Progress value={docsPercentage} className="h-2 bg-zinc-800" />
|
||||
<p className="text-xs text-zinc-500 mt-2">
|
||||
{usage.docs_remaining === -1
|
||||
? "Unlimited"
|
||||
: `${usage.docs_remaining} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pages Translated */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-zinc-400">Pages Translated</span>
|
||||
<TrendingUp className="h-4 w-4 text-teal-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{usage.pages_used}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-2">
|
||||
Max {usage.max_pages_per_doc === -1 ? "unlimited" : usage.max_pages_per_doc} pages/doc
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extra Credits */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-zinc-400">Extra Credits</span>
|
||||
<Zap className="h-4 w-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{usage.extra_credits}
|
||||
</div>
|
||||
<Link href="/pricing#credits">
|
||||
<Button variant="outline" size="sm" className="mt-4 w-full border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
||||
Buy Credits
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features & Actions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Available Features */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Your Plan Features</h2>
|
||||
<ul className="space-y-3">
|
||||
{user.plan_limits.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-zinc-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Quick Actions</h2>
|
||||
<div className="space-y-2">
|
||||
<Link href="/">
|
||||
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-teal-400" />
|
||||
<span className="text-white">Translate a Document</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/services">
|
||||
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="h-5 w-5 text-blue-400" />
|
||||
<span className="text-white">Configure Providers</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{user.plan !== "free" && (
|
||||
<button
|
||||
onClick={handleManageBilling}
|
||||
className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-purple-400" />
|
||||
<span className="text-white">Manage Billing</span>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link href="/pricing">
|
||||
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="h-5 w-5 text-amber-400" />
|
||||
<span className="text-white">View Plans & Pricing</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Providers */}
|
||||
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Available Translation Providers</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
|
||||
const isAvailable = usage.allowed_providers.includes(provider);
|
||||
return (
|
||||
<Badge
|
||||
key={provider}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"capitalize",
|
||||
isAvailable
|
||||
? "border-teal-500/50 text-teal-400 bg-teal-500/10"
|
||||
: "border-zinc-700 text-zinc-500"
|
||||
)}
|
||||
>
|
||||
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
||||
{provider}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{user.plan === "free" && (
|
||||
<p className="text-sm text-zinc-500 mt-4">
|
||||
<Link href="/pricing" className="text-teal-400 hover:text-teal-300">
|
||||
Upgrade your plan
|
||||
</Link>{" "}
|
||||
to access more translation providers including Google, DeepL, and OpenAI.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
frontend/src/app/ollama-setup/page.tsx
Normal file
364
frontend/src/app/ollama-setup/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Server,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Terminal,
|
||||
Cpu,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
size: string;
|
||||
quantization: string;
|
||||
}
|
||||
|
||||
const recommendedModels: OllamaModel[] = [
|
||||
{ name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" },
|
||||
{ name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" },
|
||||
{ name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" },
|
||||
{ name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" },
|
||||
{ name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" },
|
||||
];
|
||||
|
||||
export default function OllamaSetupPage() {
|
||||
const [endpoint, setEndpoint] = useState("http://localhost:11434");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setConnectionStatus("idle");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// Test connection to Ollama
|
||||
const res = await fetch(`${endpoint}/api/tags`);
|
||||
if (!res.ok) throw new Error("Failed to connect to Ollama");
|
||||
|
||||
const data = await res.json();
|
||||
const models = data.models?.map((m: any) => m.name) || [];
|
||||
setAvailableModels(models);
|
||||
setConnectionStatus("success");
|
||||
|
||||
// Auto-select first model if available
|
||||
if (models.length > 0 && !selectedModel) {
|
||||
setSelectedModel(models[0]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setConnectionStatus("error");
|
||||
setErrorMessage(error.message || "Failed to connect to Ollama");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
// Save to localStorage or user settings
|
||||
const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel };
|
||||
localStorage.setItem("ollamaSettings", JSON.stringify(settings));
|
||||
|
||||
// Also save to user account if logged in
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
fetch("http://localhost:8000/api/auth/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ollama_endpoint: endpoint,
|
||||
ollama_model: selectedModel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
alert("Settings saved successfully!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<Badge className="mb-4 bg-orange-500/20 text-orange-400 border-orange-500/30">
|
||||
Self-Hosted
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
Configure Your Ollama Server
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||
Connect your own Ollama instance for unlimited, free translations using local AI models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What is Ollama */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-orange-400" />
|
||||
What is Ollama?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-4">
|
||||
Ollama is a free, open-source tool that lets you run large language models locally on your computer.
|
||||
With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Visit Ollama Website
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ollama/ollama"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
GitHub Repository
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Guide */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-blue-400" />
|
||||
Quick Installation Guide
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Step 1 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">1. Install Ollama</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<div>
|
||||
<span className="text-zinc-400">macOS / Linux:</span>
|
||||
<code className="ml-2 text-teal-400">curl -fsSL https://ollama.ai/install.sh | sh</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard("curl -fsSL https://ollama.ai/install.sh | sh", "install")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "install" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Windows: Download from <a href="https://ollama.ai/download" className="text-teal-400 hover:underline" target="_blank">ollama.ai/download</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">2. Pull a Translation Model</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama pull llama3.2:3b</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama pull llama3.2:3b", "pull")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "pull" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">3. Start Ollama Server</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama serve</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama serve", "serve")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "serve" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-2">
|
||||
On macOS/Windows with the desktop app, Ollama runs automatically in the background.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Models */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-purple-400" />
|
||||
Recommended Models for Translation
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white font-medium">{model.name}</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs border-zinc-700 text-zinc-400">
|
||||
<HardDrive className="h-3 w-3 mr-1" />
|
||||
{model.size}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`ollama pull ${model.name}`, model.name)}
|
||||
className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-sm text-white"
|
||||
>
|
||||
{copied === model.name ? "Copied!" : "Copy command"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-4">
|
||||
💡 Tip: For best results with limited RAM (8GB), use <code className="text-teal-400">llama3.2:3b</code>.
|
||||
With 16GB+ RAM, try <code className="text-teal-400">mistral:7b</code> or larger.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Configure Connection</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint" className="text-zinc-300">Ollama Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="endpoint"
|
||||
type="url"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
className="bg-teal-500 hover:bg-teal-600 text-white whitespace-nowrap"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{connectionStatus === "success" && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-green-400">Connected successfully! Found {availableModels.length} model(s).</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus === "error" && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<span className="text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
{availableModels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-zinc-300">Select Model</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{availableModels.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => setSelectedModel(model)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-colors",
|
||||
selectedModel === model
|
||||
? "border-teal-500 bg-teal-500/10 text-teal-400"
|
||||
: "border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||
)}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{connectionStatus === "success" && selectedModel && (
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-gradient-to-r from-teal-500/10 to-purple-500/10 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Why Self-Host?</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="font-medium text-white mb-1">Complete Privacy</h3>
|
||||
<p className="text-sm text-zinc-400">Your documents never leave your computer</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">♾️</div>
|
||||
<h3 className="font-medium text-white mb-1">Unlimited Usage</h3>
|
||||
<p className="text-sm text-zinc-400">No monthly limits or quotas</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">💰</div>
|
||||
<h3 className="font-medium text-white mb-1">Free Forever</h3>
|
||||
<p className="text-sm text-zinc-400">No subscription or API costs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,12 @@ import { useTranslationStore } from "@/lib/store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LandingHero,
|
||||
FeaturesSection,
|
||||
PricingPreview,
|
||||
SelfHostCTA
|
||||
} from "@/components/landing-sections";
|
||||
|
||||
export default function Home() {
|
||||
const { settings } = useTranslationStore();
|
||||
@@ -15,13 +21,20 @@ export default function Home() {
|
||||
deepl: "DeepL",
|
||||
libre: "LibreTranslate",
|
||||
webllm: "WebLLM",
|
||||
openai: "OpenAI",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-0 -m-8">
|
||||
{/* Hero Section */}
|
||||
<LandingHero />
|
||||
|
||||
{/* Upload Section */}
|
||||
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
|
||||
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
|
||||
<p className="text-zinc-400 mt-1">
|
||||
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
|
||||
</p>
|
||||
@@ -45,5 +58,34 @@ export default function Home() {
|
||||
|
||||
<FileUploader />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection />
|
||||
|
||||
{/* Pricing Preview */}
|
||||
<PricingPreview />
|
||||
|
||||
{/* Self-Host CTA */}
|
||||
<SelfHostCTA />
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800 py-8 px-8">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-500 text-white font-bold text-sm">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">© 2024 Translate Co. All rights reserved.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-zinc-500">
|
||||
<Link href="/pricing" className="hover:text-zinc-300">Pricing</Link>
|
||||
<Link href="/ollama-setup" className="hover:text-zinc-300">Self-Host</Link>
|
||||
<Link href="/terms" className="hover:text-zinc-300">Terms</Link>
|
||||
<Link href="/privacy" className="hover:text-zinc-300">Privacy</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
421
frontend/src/app/pricing/page.tsx
Normal file
421
frontend/src/app/pricing/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
features: string[];
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
providers: string[];
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
interface CreditPackage {
|
||||
credits: number;
|
||||
price: number;
|
||||
price_per_credit: number;
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
const planIcons: Record<string, any> = {
|
||||
free: Sparkles,
|
||||
starter: Zap,
|
||||
pro: Crown,
|
||||
business: Building2,
|
||||
enterprise: Building2,
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/plans");
|
||||
const data = await res.json();
|
||||
setPlans(data.plans || []);
|
||||
setCreditPackages(data.credit_packages || []);
|
||||
} catch (error) {
|
||||
// Use default plans if API fails
|
||||
setPlans([
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
features: [
|
||||
"3 documents per day",
|
||||
"Up to 10 pages per document",
|
||||
"Ollama (self-hosted) only",
|
||||
"Basic support via community",
|
||||
],
|
||||
docs_per_month: 3,
|
||||
max_pages_per_doc: 10,
|
||||
providers: ["ollama"],
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
price_monthly: 9,
|
||||
price_yearly: 90,
|
||||
features: [
|
||||
"50 documents per month",
|
||||
"Up to 50 pages per document",
|
||||
"Google Translate included",
|
||||
"LibreTranslate included",
|
||||
"Email support",
|
||||
],
|
||||
docs_per_month: 50,
|
||||
max_pages_per_doc: 50,
|
||||
providers: ["ollama", "google", "libre"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price_monthly: 29,
|
||||
price_yearly: 290,
|
||||
features: [
|
||||
"200 documents per month",
|
||||
"Up to 200 pages per document",
|
||||
"All translation providers",
|
||||
"DeepL & OpenAI included",
|
||||
"API access (1000 calls/month)",
|
||||
"Priority email support",
|
||||
],
|
||||
docs_per_month: 200,
|
||||
max_pages_per_doc: 200,
|
||||
providers: ["ollama", "google", "deepl", "openai", "libre"],
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price_monthly: 79,
|
||||
price_yearly: 790,
|
||||
features: [
|
||||
"1000 documents per month",
|
||||
"Up to 500 pages per document",
|
||||
"All translation providers",
|
||||
"Azure Translator included",
|
||||
"Unlimited API access",
|
||||
"Priority processing queue",
|
||||
"Dedicated support",
|
||||
"Team management (up to 5 users)",
|
||||
],
|
||||
docs_per_month: 1000,
|
||||
max_pages_per_doc: 500,
|
||||
providers: ["ollama", "google", "deepl", "openai", "libre", "azure"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price_monthly: -1,
|
||||
price_yearly: -1,
|
||||
features: [
|
||||
"Unlimited documents",
|
||||
"Unlimited pages",
|
||||
"Custom integrations",
|
||||
"On-premise deployment",
|
||||
"SLA guarantee",
|
||||
"24/7 dedicated support",
|
||||
"Custom AI models",
|
||||
"White-label option",
|
||||
],
|
||||
docs_per_month: -1,
|
||||
max_pages_per_doc: -1,
|
||||
providers: ["all"],
|
||||
},
|
||||
]);
|
||||
setCreditPackages([
|
||||
{ credits: 50, price: 5, price_per_credit: 0.1 },
|
||||
{ credits: 100, price: 9, price_per_credit: 0.09, popular: true },
|
||||
{ credits: 250, price: 20, price_per_credit: 0.08 },
|
||||
{ credits: 500, price: 35, price_per_credit: 0.07 },
|
||||
{ credits: 1000, price: 60, price_per_credit: 0.06 },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
// Check if user is logged in
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
window.location.href = "/auth/login?redirect=/pricing&plan=" + planId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/checkout/subscription", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan: planId,
|
||||
billing_period: isYearly ? "yearly" : "monthly",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else if (data.demo_mode) {
|
||||
alert("Upgraded to " + planId + " (demo mode)");
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16">
|
||||
<Badge className="mb-4 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
||||
Pricing
|
||||
</Badge>
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||
Choose the perfect plan for your translation needs. Start free and scale as you grow.
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="mt-8 flex items-center justify-center gap-4">
|
||||
<span className={cn("text-sm", !isYearly ? "text-white" : "text-zinc-500")}>
|
||||
Monthly
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsYearly(!isYearly)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||
isYearly ? "bg-teal-500" : "bg-zinc-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||
isYearly ? "translate-x-6" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className={cn("text-sm", isYearly ? "text-white" : "text-zinc-500")}>
|
||||
Yearly
|
||||
<Badge className="ml-2 bg-green-500/20 text-green-400 border-green-500/30 text-xs">
|
||||
Save 17%
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
||||
{plans.slice(0, 4).map((plan) => {
|
||||
const Icon = planIcons[plan.id] || Sparkles;
|
||||
const price = isYearly ? plan.price_yearly : plan.price_monthly;
|
||||
const isEnterprise = plan.id === "enterprise";
|
||||
const isPro = plan.popular;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative rounded-2xl border p-6 flex flex-col",
|
||||
isPro
|
||||
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
||||
: "border-zinc-800 bg-zinc-900/50"
|
||||
)}
|
||||
>
|
||||
{isPro && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 rounded-lg",
|
||||
isPro ? "bg-teal-500/20" : "bg-zinc-800"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", isPro ? "text-teal-400" : "text-zinc-400")} />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white">{plan.name}</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
{isEnterprise || price < 0 ? (
|
||||
<div className="text-3xl font-bold text-white">Custom</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-4xl font-bold text-white">
|
||||
${isYearly ? Math.round(price / 12) : price}
|
||||
</span>
|
||||
<span className="text-zinc-500">/month</span>
|
||||
{isYearly && price > 0 && (
|
||||
<div className="text-sm text-zinc-500">
|
||||
${price} billed yearly
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-3 mb-6 flex-1">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-zinc-300">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isPro
|
||||
? "bg-teal-500 hover:bg-teal-600 text-white"
|
||||
: "bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||
)}
|
||||
>
|
||||
{plan.id === "free"
|
||||
? "Get Started"
|
||||
: isEnterprise
|
||||
? "Contact Sales"
|
||||
: "Subscribe"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enterprise Section */}
|
||||
{plans.find((p) => p.id === "enterprise") && (
|
||||
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-purple-500/10 to-teal-500/10 p-8 mb-16">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">
|
||||
Need Enterprise Features?
|
||||
</h3>
|
||||
<p className="text-zinc-400 max-w-xl">
|
||||
Get unlimited translations, custom integrations, on-premise deployment,
|
||||
dedicated support, and SLA guarantees. Perfect for large organizations.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-purple-500 to-teal-500 hover:from-purple-600 hover:to-teal-600 text-white whitespace-nowrap"
|
||||
>
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credit Packages */}
|
||||
<div className="mb-16">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">Need Extra Pages?</h2>
|
||||
<p className="text-zinc-400">
|
||||
Buy credit packages to translate more pages. Credits never expire.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{creditPackages.map((pkg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"rounded-xl border p-4 text-center",
|
||||
pkg.popular
|
||||
? "border-teal-500 bg-teal-500/10"
|
||||
: "border-zinc-800 bg-zinc-900/50"
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<Badge className="mb-2 bg-teal-500/20 text-teal-400 border-teal-500/30 text-xs">
|
||||
Best Value
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-white">{pkg.credits}</div>
|
||||
<div className="text-sm text-zinc-500 mb-2">pages</div>
|
||||
<div className="text-xl font-semibold text-white">${pkg.price}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
${pkg.price_per_credit.toFixed(2)}/page
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3 w-full border-zinc-700 hover:bg-zinc-800"
|
||||
>
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
q: "Can I use my own Ollama instance?",
|
||||
a: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in the settings.",
|
||||
},
|
||||
{
|
||||
q: "What happens if I exceed my monthly limit?",
|
||||
a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel my subscription anytime?",
|
||||
a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.",
|
||||
},
|
||||
{
|
||||
q: "Do credits expire?",
|
||||
a: "No, purchased credits never expire and can be used anytime.",
|
||||
},
|
||||
{
|
||||
q: "What file formats are supported?",
|
||||
a: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
|
||||
},
|
||||
].map((faq, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||
<h3 className="font-medium text-white mb-2">{faq.q}</h3>
|
||||
<p className="text-sm text-zinc-400">{faq.a}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
frontend/src/components/landing-sections.tsx
Normal file
309
frontend/src/components/landing-sections.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
FileText,
|
||||
Globe2,
|
||||
Zap,
|
||||
Shield,
|
||||
Server,
|
||||
Sparkles,
|
||||
FileSpreadsheet,
|
||||
Presentation
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export function LandingHero() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/10 via-transparent to-purple-500/10" />
|
||||
|
||||
{/* Hero content */}
|
||||
<div className="relative px-4 py-16 sm:py-24">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<Badge className="mb-6 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
||||
<Sparkles className="h-3 w-3 mr-1" />
|
||||
AI-Powered Document Translation
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Translate Documents{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-cyan-400">
|
||||
Instantly
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-zinc-400 mb-8 max-w-2xl mx-auto">
|
||||
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
|
||||
all formatting, styles, and layouts. Powered by AI.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
||||
{user ? (
|
||||
<Link href="#upload">
|
||||
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
||||
Start Translating
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/register">
|
||||
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
||||
Get Started Free
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button size="lg" variant="outline" className="border-zinc-700 text-white hover:bg-zinc-800">
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supported formats */}
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||
<FileText className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm text-zinc-300">Word (.docx)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||
<FileSpreadsheet className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-zinc-300">Excel (.xlsx)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||
<Presentation className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-sm text-zinc-300">PowerPoint (.pptx)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
const features = [
|
||||
{
|
||||
icon: Globe2,
|
||||
title: "100+ Languages",
|
||||
description: "Translate between any language pair with high accuracy using AI models",
|
||||
color: "text-blue-400",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Preserve Formatting",
|
||||
description: "All styles, fonts, colors, tables, and charts remain intact",
|
||||
color: "text-green-400",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Batch processing translates entire documents in seconds",
|
||||
color: "text-amber-400",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure & Private",
|
||||
description: "Your documents are encrypted and never stored permanently",
|
||||
color: "text-purple-400",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Self-Host Option",
|
||||
description: "Use your own Ollama server for complete privacy and unlimited usage",
|
||||
color: "text-orange-400",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Multiple AI Providers",
|
||||
description: "Choose from Google, DeepL, OpenAI, or local Ollama models",
|
||||
color: "text-teal-400",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-16 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Everything You Need for Document Translation
|
||||
</h2>
|
||||
<p className="text-zinc-400 max-w-2xl mx-auto">
|
||||
Professional-grade translation with enterprise features, available to everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
||||
>
|
||||
<Icon className={cn("h-8 w-8 mb-4", feature.color)} />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||
<p className="text-zinc-400 text-sm">{feature.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingPreview() {
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
description: "Perfect for trying out",
|
||||
features: ["3 documents/day", "10 pages/doc", "Ollama only"],
|
||||
cta: "Get Started",
|
||||
href: "/auth/register",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
period: "/month",
|
||||
description: "For professionals",
|
||||
features: ["200 documents/month", "All providers", "API access", "Priority support"],
|
||||
cta: "Start Free Trial",
|
||||
href: "/pricing",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Business",
|
||||
price: "$79",
|
||||
period: "/month",
|
||||
description: "For teams",
|
||||
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
|
||||
cta: "Contact Sales",
|
||||
href: "/pricing",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="py-16 px-4 bg-zinc-900/50">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-zinc-400">
|
||||
Start free, upgrade when you need more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.name}
|
||||
className={cn(
|
||||
"relative p-6 rounded-xl border",
|
||||
plan.popular
|
||||
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
||||
: "border-zinc-800 bg-zinc-900/50"
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<h3 className="text-xl font-semibold text-white mb-1">{plan.name}</h3>
|
||||
<p className="text-sm text-zinc-400 mb-4">{plan.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<span className="text-3xl font-bold text-white">{plan.price}</span>
|
||||
{plan.period && <span className="text-zinc-500">{plan.period}</span>}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<Check className="h-4 w-4 text-teal-400" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link href={plan.href}>
|
||||
<Button
|
||||
className={cn(
|
||||
"w-full",
|
||||
plan.popular
|
||||
? "bg-teal-500 hover:bg-teal-600 text-white"
|
||||
: "bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||
)}
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<Link href="/pricing" className="text-teal-400 hover:text-teal-300 text-sm">
|
||||
View all plans and features →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelfHostCTA() {
|
||||
return (
|
||||
<div className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-orange-500/10 to-amber-500/10 p-8 text-center">
|
||||
<Server className="h-12 w-12 text-orange-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">
|
||||
Prefer Self-Hosting?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-6 max-w-xl mx-auto">
|
||||
Run your own Ollama server for complete privacy and unlimited translations.
|
||||
No API costs, no quotas, your data stays on your machine.
|
||||
</p>
|
||||
<Link href="/ollama-setup">
|
||||
<Button className="bg-orange-500 hover:bg-orange-600 text-white">
|
||||
Setup Ollama
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Settings,
|
||||
@@ -9,6 +10,11 @@ import {
|
||||
BookText,
|
||||
Upload,
|
||||
Shield,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Crown,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -16,6 +22,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -55,6 +69,35 @@ const adminNavigation = [
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for user in localStorage
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: "bg-zinc-600",
|
||||
starter: "bg-blue-500",
|
||||
pro: "bg-teal-500",
|
||||
business: "bg-purple-500",
|
||||
enterprise: "bg-amber-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
@@ -96,6 +139,53 @@ export function Sidebar() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* User Section */}
|
||||
{user && (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Account</p>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/dashboard"
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>View your usage and settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/pricing"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>Upgrade Plan</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>View plans and pricing</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Section */}
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Admin</p>
|
||||
@@ -130,15 +220,41 @@ export function Sidebar() {
|
||||
|
||||
{/* User section at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium">
|
||||
U
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-white">User</span>
|
||||
<span className="text-xs text-zinc-500">Translator</span>
|
||||
<span className="text-sm font-medium text-white">{user.name}</span>
|
||||
<Badge className={cn("text-xs mt-0.5", planColors[user.plan] || "bg-zinc-600")}>
|
||||
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Link href="/auth/login" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white text-sm font-medium transition-colors">
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign In
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/auth/register" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-500 hover:bg-teal-600 text-white text-sm font-medium transition-colors">
|
||||
Get Started Free
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
|
||||
288
k8s/deployment.yaml
Normal file
288
k8s/deployment.yaml
Normal file
@@ -0,0 +1,288 @@
|
||||
# ============================================
|
||||
# Document Translation API - Kubernetes Deployment
|
||||
# ============================================
|
||||
# Apply with: kubectl apply -f k8s/
|
||||
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: translate-api
|
||||
labels:
|
||||
app: translate-api
|
||||
|
||||
---
|
||||
# ConfigMap for application settings
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: translate-config
|
||||
namespace: translate-api
|
||||
data:
|
||||
TRANSLATION_SERVICE: "ollama"
|
||||
OLLAMA_BASE_URL: "http://ollama-service:11434"
|
||||
OLLAMA_MODEL: "llama3"
|
||||
MAX_FILE_SIZE_MB: "50"
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE: "60"
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE: "10"
|
||||
LOG_LEVEL: "INFO"
|
||||
|
||||
---
|
||||
# Secret for sensitive data
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: translate-secrets
|
||||
namespace: translate-api
|
||||
type: Opaque
|
||||
stringData:
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD: "changeme123" # Change in production!
|
||||
DEEPL_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
|
||||
---
|
||||
# Backend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: translate-api-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: translate-config
|
||||
- secretRef:
|
||||
name: translate-secrets
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
- name: outputs
|
||||
mountPath: /app/outputs
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: uploads-pvc
|
||||
- name: outputs
|
||||
persistentVolumeClaim:
|
||||
claimName: outputs-pvc
|
||||
|
||||
---
|
||||
# Backend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# Frontend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: translate-api-frontend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "http://backend-service:8000"
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
|
||||
---
|
||||
# Frontend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: frontend
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# Ingress for external access
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: translate-ingress
|
||||
namespace: translate-api
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- translate.yourdomain.com
|
||||
secretName: translate-tls
|
||||
rules:
|
||||
- host: translate.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /translate
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /health
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /admin
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend-service
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
---
|
||||
# Persistent Volume Claims
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: uploads-pvc
|
||||
namespace: translate-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: outputs-pvc
|
||||
namespace: translate-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
|
||||
---
|
||||
# Horizontal Pod Autoscaler for Backend
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: backend-hpa
|
||||
namespace: translate-api
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: backend
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
6
main.py
6
main.py
@@ -22,6 +22,9 @@ from config import config
|
||||
from translators import excel_translator, word_translator, pptx_translator
|
||||
from utils import file_handler, handle_translation_error, DocumentProcessingError
|
||||
|
||||
# Import auth routes
|
||||
from routes.auth_routes import router as auth_router
|
||||
|
||||
# Import SaaS middleware
|
||||
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
|
||||
from middleware.security import SecurityHeadersMiddleware, RequestLoggingMiddleware, ErrorHandlingMiddleware
|
||||
@@ -174,6 +177,9 @@ static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
# Include auth routes
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
# Custom exception handler for ValidationError
|
||||
@app.exception_handler(ValidationError)
|
||||
|
||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
250
models/subscription.py
Normal file
250
models/subscription.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Subscription and User models for the monetization system
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PlanType(str, Enum):
|
||||
FREE = "free"
|
||||
STARTER = "starter"
|
||||
PRO = "pro"
|
||||
BUSINESS = "business"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
|
||||
class SubscriptionStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
CANCELED = "canceled"
|
||||
PAST_DUE = "past_due"
|
||||
TRIALING = "trialing"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
# Plan definitions with limits
|
||||
PLANS = {
|
||||
PlanType.FREE: {
|
||||
"name": "Free",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"docs_per_month": 3,
|
||||
"max_pages_per_doc": 10,
|
||||
"max_file_size_mb": 5,
|
||||
"providers": ["ollama"], # Only self-hosted
|
||||
"features": [
|
||||
"3 documents per day",
|
||||
"Up to 10 pages per document",
|
||||
"Ollama (self-hosted) only",
|
||||
"Basic support via community",
|
||||
],
|
||||
"api_access": False,
|
||||
"priority_processing": False,
|
||||
"stripe_price_id_monthly": None,
|
||||
"stripe_price_id_yearly": None,
|
||||
},
|
||||
PlanType.STARTER: {
|
||||
"name": "Starter",
|
||||
"price_monthly": 9,
|
||||
"price_yearly": 90, # 2 months free
|
||||
"docs_per_month": 50,
|
||||
"max_pages_per_doc": 50,
|
||||
"max_file_size_mb": 25,
|
||||
"providers": ["ollama", "google", "libre"],
|
||||
"features": [
|
||||
"50 documents per month",
|
||||
"Up to 50 pages per document",
|
||||
"Google Translate included",
|
||||
"LibreTranslate included",
|
||||
"Email support",
|
||||
],
|
||||
"api_access": False,
|
||||
"priority_processing": False,
|
||||
"stripe_price_id_monthly": "price_starter_monthly",
|
||||
"stripe_price_id_yearly": "price_starter_yearly",
|
||||
},
|
||||
PlanType.PRO: {
|
||||
"name": "Pro",
|
||||
"price_monthly": 29,
|
||||
"price_yearly": 290, # 2 months free
|
||||
"docs_per_month": 200,
|
||||
"max_pages_per_doc": 200,
|
||||
"max_file_size_mb": 100,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre"],
|
||||
"features": [
|
||||
"200 documents per month",
|
||||
"Up to 200 pages per document",
|
||||
"All translation providers",
|
||||
"DeepL & OpenAI included",
|
||||
"API access (1000 calls/month)",
|
||||
"Priority email support",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": 1000,
|
||||
"priority_processing": True,
|
||||
"stripe_price_id_monthly": "price_pro_monthly",
|
||||
"stripe_price_id_yearly": "price_pro_yearly",
|
||||
},
|
||||
PlanType.BUSINESS: {
|
||||
"name": "Business",
|
||||
"price_monthly": 79,
|
||||
"price_yearly": 790, # 2 months free
|
||||
"docs_per_month": 1000,
|
||||
"max_pages_per_doc": 500,
|
||||
"max_file_size_mb": 250,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure"],
|
||||
"features": [
|
||||
"1000 documents per month",
|
||||
"Up to 500 pages per document",
|
||||
"All translation providers",
|
||||
"Azure Translator included",
|
||||
"Unlimited API access",
|
||||
"Priority processing queue",
|
||||
"Dedicated support",
|
||||
"Team management (up to 5 users)",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": -1, # Unlimited
|
||||
"priority_processing": True,
|
||||
"team_seats": 5,
|
||||
"stripe_price_id_monthly": "price_business_monthly",
|
||||
"stripe_price_id_yearly": "price_business_yearly",
|
||||
},
|
||||
PlanType.ENTERPRISE: {
|
||||
"name": "Enterprise",
|
||||
"price_monthly": -1, # Custom
|
||||
"price_yearly": -1,
|
||||
"docs_per_month": -1, # Unlimited
|
||||
"max_pages_per_doc": -1,
|
||||
"max_file_size_mb": -1,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure", "custom"],
|
||||
"features": [
|
||||
"Unlimited documents",
|
||||
"Unlimited pages",
|
||||
"Custom integrations",
|
||||
"On-premise deployment",
|
||||
"SLA guarantee",
|
||||
"24/7 dedicated support",
|
||||
"Custom AI models",
|
||||
"White-label option",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": -1,
|
||||
"priority_processing": True,
|
||||
"team_seats": -1, # Unlimited
|
||||
"stripe_price_id_monthly": None, # Contact sales
|
||||
"stripe_price_id_yearly": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
email_verified: bool = False
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
# Subscription info
|
||||
plan: PlanType = PlanType.FREE
|
||||
subscription_status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
stripe_customer_id: Optional[str] = None
|
||||
stripe_subscription_id: Optional[str] = None
|
||||
subscription_ends_at: Optional[datetime] = None
|
||||
|
||||
# Usage tracking
|
||||
docs_translated_this_month: int = 0
|
||||
pages_translated_this_month: int = 0
|
||||
api_calls_this_month: int = 0
|
||||
usage_reset_date: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Extra credits (purchased separately)
|
||||
extra_credits: int = 0 # Each credit = 1 page
|
||||
|
||||
# Settings
|
||||
default_source_lang: str = "auto"
|
||||
default_target_lang: str = "en"
|
||||
default_provider: str = "google"
|
||||
|
||||
# Ollama self-hosted config
|
||||
ollama_endpoint: Optional[str] = None
|
||||
ollama_model: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
avatar_url: Optional[str] = None
|
||||
plan: PlanType
|
||||
subscription_status: SubscriptionStatus
|
||||
docs_translated_this_month: int
|
||||
pages_translated_this_month: int
|
||||
api_calls_this_month: int
|
||||
extra_credits: int
|
||||
created_at: datetime
|
||||
|
||||
# Plan limits for display
|
||||
plan_limits: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class Subscription(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
plan: PlanType
|
||||
status: SubscriptionStatus
|
||||
stripe_subscription_id: Optional[str] = None
|
||||
stripe_customer_id: Optional[str] = None
|
||||
current_period_start: datetime
|
||||
current_period_end: datetime
|
||||
cancel_at_period_end: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UsageRecord(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
document_name: str
|
||||
document_type: str # excel, word, pptx
|
||||
pages_count: int
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
provider: str
|
||||
processing_time_seconds: float
|
||||
credits_used: int
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class CreditPurchase(BaseModel):
|
||||
"""For buying extra credits (pay-per-use)"""
|
||||
id: str
|
||||
user_id: str
|
||||
credits_amount: int
|
||||
price_paid: float # in cents
|
||||
stripe_payment_id: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
# Credit packages for purchase
|
||||
CREDIT_PACKAGES = [
|
||||
{"credits": 50, "price": 5.00, "price_per_credit": 0.10, "stripe_price_id": "price_credits_50"},
|
||||
{"credits": 100, "price": 9.00, "price_per_credit": 0.09, "stripe_price_id": "price_credits_100", "popular": True},
|
||||
{"credits": 250, "price": 20.00, "price_per_credit": 0.08, "stripe_price_id": "price_credits_250"},
|
||||
{"credits": 500, "price": 35.00, "price_per_credit": 0.07, "stripe_price_id": "price_credits_500"},
|
||||
{"credits": 1000, "price": 60.00, "price_per_credit": 0.06, "stripe_price_id": "price_credits_1000"},
|
||||
]
|
||||
@@ -7,6 +7,7 @@ python-pptx==0.6.23
|
||||
deep-translator==1.11.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
pydantic[email]==2.5.3
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.2.0
|
||||
matplotlib==3.8.2
|
||||
@@ -18,3 +19,9 @@ openai>=1.0.0
|
||||
# SaaS robustness dependencies
|
||||
psutil==5.9.8
|
||||
python-magic-bin==0.4.14 # For Windows, use python-magic on Linux
|
||||
|
||||
# Authentication & Payments
|
||||
PyJWT==2.8.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
stripe==7.0.0
|
||||
|
||||
|
||||
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
281
routes/auth_routes.py
Normal file
281
routes/auth_routes.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Authentication and User API routes
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
|
||||
from services.auth_service import (
|
||||
create_user, authenticate_user, get_user_by_id,
|
||||
create_access_token, create_refresh_token, verify_token,
|
||||
check_usage_limits, update_user
|
||||
)
|
||||
from services.payment_service import (
|
||||
create_checkout_session, create_credits_checkout,
|
||||
cancel_subscription, get_billing_portal_url,
|
||||
handle_webhook, is_stripe_configured
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan: PlanType
|
||||
billing_period: str = "monthly"
|
||||
|
||||
|
||||
class CreditsCheckoutRequest(BaseModel):
|
||||
package_index: int
|
||||
|
||||
|
||||
# Dependency to get current user
|
||||
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
return user
|
||||
|
||||
|
||||
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def user_to_response(user) -> UserResponse:
|
||||
"""Convert User to UserResponse with plan limits"""
|
||||
plan_limits = PLANS[user.plan]
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
plan=user.plan,
|
||||
subscription_status=user.subscription_status,
|
||||
docs_translated_this_month=user.docs_translated_this_month,
|
||||
pages_translated_this_month=user.pages_translated_this_month,
|
||||
api_calls_this_month=user.api_calls_this_month,
|
||||
extra_credits=user.extra_credits,
|
||||
created_at=user.created_at,
|
||||
plan_limits={
|
||||
"docs_per_month": plan_limits["docs_per_month"],
|
||||
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
||||
"providers": plan_limits["providers"],
|
||||
"features": plan_limits["features"],
|
||||
"api_access": plan_limits.get("api_access", False),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Auth endpoints
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(user_create: UserCreate):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
user = create_user(user_create)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(credentials: UserLogin):
|
||||
"""Login with email and password"""
|
||||
user = authenticate_user(credentials.email, credentials.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_tokens(request: RefreshRequest):
|
||||
"""Refresh access token"""
|
||||
payload = verify_token(request.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user=Depends(require_user)):
|
||||
"""Get current user info"""
|
||||
return user_to_response(user)
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(user=Depends(require_user)):
|
||||
"""Get current usage and limits"""
|
||||
return check_usage_limits(user)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
|
||||
"""Update user settings"""
|
||||
allowed_fields = [
|
||||
"default_source_lang", "default_target_lang", "default_provider",
|
||||
"ollama_endpoint", "ollama_model", "name"
|
||||
]
|
||||
|
||||
updates = {k: v for k, v in settings.items() if k in allowed_fields}
|
||||
updated_user = update_user(user.id, updates)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(status_code=400, detail="Failed to update settings")
|
||||
|
||||
return user_to_response(updated_user)
|
||||
|
||||
|
||||
# Plans endpoint (public)
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
"""Get all available plans"""
|
||||
plans = []
|
||||
for plan_type, config in PLANS.items():
|
||||
plans.append({
|
||||
"id": plan_type.value,
|
||||
"name": config["name"],
|
||||
"price_monthly": config["price_monthly"],
|
||||
"price_yearly": config["price_yearly"],
|
||||
"features": config["features"],
|
||||
"docs_per_month": config["docs_per_month"],
|
||||
"max_pages_per_doc": config["max_pages_per_doc"],
|
||||
"max_file_size_mb": config["max_file_size_mb"],
|
||||
"providers": config["providers"],
|
||||
"api_access": config.get("api_access", False),
|
||||
"popular": plan_type == PlanType.PRO,
|
||||
})
|
||||
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
|
||||
|
||||
|
||||
# Payment endpoints
|
||||
@router.post("/checkout/subscription")
|
||||
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for subscription"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - just upgrade the user
|
||||
update_user(user.id, {"plan": request.plan.value})
|
||||
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
|
||||
|
||||
result = await create_checkout_session(
|
||||
user.id,
|
||||
request.plan,
|
||||
request.billing_period
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/checkout/credits")
|
||||
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for credits"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - add credits directly
|
||||
from services.auth_service import add_credits
|
||||
credits = CREDIT_PACKAGES[request.package_index]["credits"]
|
||||
add_credits(user.id, credits)
|
||||
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
|
||||
|
||||
result = await create_credits_checkout(user.id, request.package_index)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/subscription/cancel")
|
||||
async def cancel_user_subscription(user=Depends(require_user)):
|
||||
"""Cancel subscription"""
|
||||
result = await cancel_subscription(user.id)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/billing-portal")
|
||||
async def get_billing_portal(user=Depends(require_user)):
|
||||
"""Get Stripe billing portal URL"""
|
||||
url = await get_billing_portal_url(user.id)
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Billing portal not available")
|
||||
|
||||
return {"url": url}
|
||||
|
||||
|
||||
# Stripe webhook
|
||||
@router.post("/webhook/stripe")
|
||||
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
||||
"""Handle Stripe webhooks"""
|
||||
payload = await request.body()
|
||||
|
||||
result = await handle_webhook(payload, stripe_signature or "")
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
67
scripts/backup.sh
Normal file
67
scripts/backup.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Backup Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/backup.sh [backup_dir]
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="${1:-./backups}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="translate_backup_$TIMESTAMP"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}Starting backup: $BACKUP_NAME${NC}"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "$BACKUP_DIR/$BACKUP_NAME"
|
||||
|
||||
# Backup uploaded files
|
||||
if [ -d "./uploads" ]; then
|
||||
echo "Backing up uploads..."
|
||||
cp -r ./uploads "$BACKUP_DIR/$BACKUP_NAME/"
|
||||
fi
|
||||
|
||||
# Backup output files
|
||||
if [ -d "./outputs" ]; then
|
||||
echo "Backing up outputs..."
|
||||
cp -r ./outputs "$BACKUP_DIR/$BACKUP_NAME/"
|
||||
fi
|
||||
|
||||
# Backup configuration
|
||||
echo "Backing up configuration..."
|
||||
cp .env* "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||
cp docker-compose*.yml "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||
|
||||
# Backup Docker volumes (if using Docker)
|
||||
if command -v docker &> /dev/null; then
|
||||
echo "Backing up Docker volumes..."
|
||||
|
||||
# Get volume names
|
||||
VOLUMES=$(docker volume ls --format "{{.Name}}" | grep translate || true)
|
||||
|
||||
for vol in $VOLUMES; do
|
||||
echo " Backing up volume: $vol"
|
||||
docker run --rm \
|
||||
-v "$vol:/data:ro" \
|
||||
-v "$(pwd)/$BACKUP_DIR/$BACKUP_NAME:/backup" \
|
||||
alpine tar czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Compress backup
|
||||
echo "Compressing backup..."
|
||||
cd "$BACKUP_DIR"
|
||||
tar czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
|
||||
rm -rf "$BACKUP_NAME"
|
||||
|
||||
# Cleanup old backups (keep last 7)
|
||||
echo "Cleaning old backups..."
|
||||
ls -t translate_backup_*.tar.gz 2>/dev/null | tail -n +8 | xargs rm -f 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Backup complete: $BACKUP_DIR/${BACKUP_NAME}.tar.gz${NC}"
|
||||
168
scripts/deploy.sh
Normal file
168
scripts/deploy.sh
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Deployment Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/deploy.sh [environment] [options]
|
||||
# Example: ./scripts/deploy.sh production --with-ollama
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENVIRONMENT="${1:-production}"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
# Parse options
|
||||
PROFILES=""
|
||||
while [[ $# -gt 1 ]]; do
|
||||
case $2 in
|
||||
--with-ollama)
|
||||
PROFILES="$PROFILES --profile with-ollama"
|
||||
shift
|
||||
;;
|
||||
--with-cache)
|
||||
PROFILES="$PROFILES --profile with-cache"
|
||||
shift
|
||||
;;
|
||||
--with-monitoring)
|
||||
PROFILES="$PROFILES --profile with-monitoring"
|
||||
shift
|
||||
;;
|
||||
--full)
|
||||
PROFILES="--profile with-ollama --profile with-cache --profile with-monitoring"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} Document Translation API Deployment${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
echo -e "${YELLOW}Checking prerequisites...${NC}"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker Compose is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Docker and Docker Compose are installed${NC}"
|
||||
|
||||
# Check environment file
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if [ "$ENVIRONMENT" == "production" ]; then
|
||||
ENV_FILE=".env.production"
|
||||
else
|
||||
ENV_FILE=".env"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo -e "${YELLOW}Creating $ENV_FILE from template...${NC}"
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example "$ENV_FILE"
|
||||
echo -e "${YELLOW}Please edit $ENV_FILE with your configuration${NC}"
|
||||
else
|
||||
echo -e "${RED}Error: No environment file found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Environment file: $ENV_FILE${NC}"
|
||||
|
||||
# Load environment
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
# Create SSL directory if needed
|
||||
if [ ! -d "docker/nginx/ssl" ]; then
|
||||
mkdir -p docker/nginx/ssl
|
||||
echo -e "${YELLOW}Created SSL directory. Add your certificates:${NC}"
|
||||
echo " - docker/nginx/ssl/fullchain.pem"
|
||||
echo " - docker/nginx/ssl/privkey.pem"
|
||||
echo " - docker/nginx/ssl/chain.pem"
|
||||
|
||||
# Generate self-signed cert for testing
|
||||
if [ "$ENVIRONMENT" != "production" ]; then
|
||||
echo -e "${YELLOW}Generating self-signed certificate for development...${NC}"
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" 2>/dev/null
|
||||
cp docker/nginx/ssl/fullchain.pem docker/nginx/ssl/chain.pem
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
echo ""
|
||||
echo -e "${YELLOW}Building containers...${NC}"
|
||||
docker compose --env-file "$ENV_FILE" build
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Starting services...${NC}"
|
||||
docker compose --env-file "$ENV_FILE" $PROFILES up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
echo ""
|
||||
echo -e "${YELLOW}Waiting for services to be ready...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Health check
|
||||
echo ""
|
||||
echo -e "${YELLOW}Running health checks...${NC}"
|
||||
|
||||
BACKEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
|
||||
if [ "$BACKEND_HEALTH" == "200" ]; then
|
||||
echo -e "${GREEN}✓ Backend is healthy${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Backend health check failed (HTTP $BACKEND_HEALTH)${NC}"
|
||||
fi
|
||||
|
||||
FRONTEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000")
|
||||
if [ "$FRONTEND_HEALTH" == "200" ]; then
|
||||
echo -e "${GREEN}✓ Frontend is healthy${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Frontend health check failed (HTTP $FRONTEND_HEALTH)${NC}"
|
||||
fi
|
||||
|
||||
# Show status
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} Deployment Complete!${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Services running:"
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Access your application:${NC}"
|
||||
echo -e " Frontend: http://localhost (or https://localhost)"
|
||||
echo -e " API: http://localhost/api"
|
||||
echo -e " Admin: http://localhost/admin"
|
||||
echo -e " Health: http://localhost/health"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Useful commands:${NC}"
|
||||
echo " View logs: docker compose logs -f"
|
||||
echo " Stop: docker compose down"
|
||||
echo " Restart: docker compose restart"
|
||||
echo " Update: ./scripts/deploy.sh $ENVIRONMENT"
|
||||
87
scripts/health-check.sh
Normal file
87
scripts/health-check.sh
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Health Check Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/health-check.sh [--verbose]
|
||||
|
||||
set -e
|
||||
|
||||
VERBOSE=false
|
||||
if [ "$1" == "--verbose" ]; then
|
||||
VERBOSE=true
|
||||
fi
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
|
||||
FRONTEND_URL="${FRONTEND_URL:-http://localhost:3000}"
|
||||
NGINX_URL="${NGINX_URL:-http://localhost}"
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
check_service() {
|
||||
local name=$1
|
||||
local url=$2
|
||||
local expected=${3:-200}
|
||||
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$response" == "$expected" ]; then
|
||||
echo -e "${GREEN}✓ $name: OK (HTTP $response)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ $name: FAILED (HTTP $response, expected $expected)${NC}"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
if [ "$VERBOSE" == "true" ] && [ "$response" == "200" ]; then
|
||||
echo " Response:"
|
||||
curl -s "$url" 2>/dev/null | head -c 500
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
echo "========================================="
|
||||
echo " Health Check - Document Translation API"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Check backend
|
||||
echo "Backend Services:"
|
||||
check_service "API Health" "$BACKEND_URL/health"
|
||||
check_service "API Root" "$BACKEND_URL/"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check frontend
|
||||
echo "Frontend Services:"
|
||||
check_service "Frontend" "$FRONTEND_URL"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check nginx (if running)
|
||||
echo "Proxy Services:"
|
||||
check_service "Nginx" "$NGINX_URL/health"
|
||||
|
||||
echo ""
|
||||
|
||||
# Docker health
|
||||
if command -v docker &> /dev/null; then
|
||||
echo "Docker Container Status:"
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null || echo " Docker Compose not running"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}All health checks passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}Some health checks failed!${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
79
scripts/setup-ssl.sh
Normal file
79
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - SSL Setup Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/setup-ssl.sh <domain> <email>
|
||||
# Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="${1:-}"
|
||||
EMAIL="${2:-}"
|
||||
|
||||
if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then
|
||||
echo "Usage: ./scripts/setup-ssl.sh <domain> <email>"
|
||||
echo "Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}Setting up SSL for $DOMAIN${NC}"
|
||||
|
||||
# Create directory for certbot
|
||||
mkdir -p ./docker/certbot/www
|
||||
mkdir -p ./docker/certbot/conf
|
||||
|
||||
# Create initial nginx config for ACME challenge
|
||||
cat > ./docker/nginx/conf.d/certbot.conf << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start nginx with HTTP only
|
||||
echo "Starting nginx for certificate request..."
|
||||
docker compose up -d nginx
|
||||
|
||||
# Request certificate
|
||||
echo "Requesting Let's Encrypt certificate..."
|
||||
docker run --rm \
|
||||
-v "$(pwd)/docker/certbot/www:/var/www/certbot" \
|
||||
-v "$(pwd)/docker/certbot/conf:/etc/letsencrypt" \
|
||||
certbot/certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "$DOMAIN"
|
||||
|
||||
# Copy certificates
|
||||
echo "Installing certificates..."
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/fullchain.pem ./docker/nginx/ssl/
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/privkey.pem ./docker/nginx/ssl/
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/chain.pem ./docker/nginx/ssl/
|
||||
|
||||
# Remove temporary config
|
||||
rm ./docker/nginx/conf.d/certbot.conf
|
||||
|
||||
# Restart nginx with SSL
|
||||
echo "Restarting nginx with SSL..."
|
||||
docker compose restart nginx
|
||||
|
||||
echo -e "${GREEN}SSL setup complete for $DOMAIN${NC}"
|
||||
echo ""
|
||||
echo "To auto-renew certificates, add this to crontab:"
|
||||
echo "0 0 1 * * cd $(pwd) && ./scripts/renew-ssl.sh"
|
||||
262
services/auth_service.py
Normal file
262
services/auth_service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Authentication service with JWT tokens and password hashing
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Try to import optional dependencies
|
||||
try:
|
||||
import jwt
|
||||
JWT_AVAILABLE = True
|
||||
except ImportError:
|
||||
JWT_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from passlib.context import CryptContext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
PASSLIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
PASSLIB_AVAILABLE = False
|
||||
|
||||
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
|
||||
|
||||
|
||||
# Configuration
|
||||
SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
# Simple file-based storage (replace with database in production)
|
||||
USERS_FILE = Path("data/users.json")
|
||||
USERS_FILE.parent.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt or fallback to SHA256"""
|
||||
if PASSLIB_AVAILABLE:
|
||||
return pwd_context.hash(password)
|
||||
else:
|
||||
# Fallback to SHA256 with salt
|
||||
salt = secrets.token_hex(16)
|
||||
hashed = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
|
||||
return f"sha256${salt}${hashed}"
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
if PASSLIB_AVAILABLE and not hashed_password.startswith("sha256$"):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
else:
|
||||
# Fallback SHA256 verification
|
||||
parts = hashed_password.split("$")
|
||||
if len(parts) == 3 and parts[0] == "sha256":
|
||||
salt = parts[1]
|
||||
expected_hash = parts[2]
|
||||
actual_hash = hashlib.sha256(f"{salt}{plain_password}".encode()).hexdigest()
|
||||
return secrets.compare_digest(actual_hash, expected_hash)
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token"""
|
||||
if not JWT_AVAILABLE:
|
||||
# Fallback to simple token
|
||||
token_data = {
|
||||
"user_id": user_id,
|
||||
"exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).isoformat()
|
||||
}
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
|
||||
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
|
||||
to_encode = {"sub": user_id, "exp": expire, "type": "access"}
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
"""Create a JWT refresh token"""
|
||||
if not JWT_AVAILABLE:
|
||||
token_data = {
|
||||
"user_id": user_id,
|
||||
"exp": (datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
|
||||
}
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
|
||||
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify a JWT token and return payload"""
|
||||
if not JWT_AVAILABLE:
|
||||
try:
|
||||
import base64
|
||||
data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
|
||||
exp = datetime.fromisoformat(data["exp"])
|
||||
if exp < datetime.utcnow():
|
||||
return None
|
||||
return {"sub": data["user_id"]}
|
||||
except:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def load_users() -> Dict[str, Dict]:
|
||||
"""Load users from file storage"""
|
||||
if USERS_FILE.exists():
|
||||
try:
|
||||
with open(USERS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_users(users: Dict[str, Dict]):
|
||||
"""Save users to file storage"""
|
||||
with open(USERS_FILE, 'w') as f:
|
||||
json.dump(users, f, indent=2, default=str)
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> Optional[User]:
|
||||
"""Get a user by email"""
|
||||
users = load_users()
|
||||
for user_data in users.values():
|
||||
if user_data.get("email", "").lower() == email.lower():
|
||||
return User(**user_data)
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> Optional[User]:
|
||||
"""Get a user by ID"""
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
return User(**users[user_id])
|
||||
return None
|
||||
|
||||
|
||||
def create_user(user_create: UserCreate) -> User:
|
||||
"""Create a new user"""
|
||||
users = load_users()
|
||||
|
||||
# Check if email exists
|
||||
if get_user_by_email(user_create.email):
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
# Generate user ID
|
||||
user_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=user_create.email,
|
||||
name=user_create.name,
|
||||
password_hash=hash_password(user_create.password),
|
||||
plan=PlanType.FREE,
|
||||
subscription_status=SubscriptionStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
users[user_id] = user.model_dump()
|
||||
save_users(users)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(email: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user with email and password"""
|
||||
user = get_user_by_email(email)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
|
||||
"""Update a user's data"""
|
||||
users = load_users()
|
||||
if user_id not in users:
|
||||
return None
|
||||
|
||||
users[user_id].update(updates)
|
||||
users[user_id]["updated_at"] = datetime.utcnow().isoformat()
|
||||
save_users(users)
|
||||
|
||||
return User(**users[user_id])
|
||||
|
||||
|
||||
def check_usage_limits(user: User) -> Dict[str, Any]:
|
||||
"""Check if user has exceeded their plan limits"""
|
||||
plan = PLANS[user.plan]
|
||||
|
||||
# Reset usage if it's a new month
|
||||
now = datetime.utcnow()
|
||||
if user.usage_reset_date.month != now.month or user.usage_reset_date.year != now.year:
|
||||
update_user(user.id, {
|
||||
"docs_translated_this_month": 0,
|
||||
"pages_translated_this_month": 0,
|
||||
"api_calls_this_month": 0,
|
||||
"usage_reset_date": now.isoformat()
|
||||
})
|
||||
user.docs_translated_this_month = 0
|
||||
user.pages_translated_this_month = 0
|
||||
user.api_calls_this_month = 0
|
||||
|
||||
docs_limit = plan["docs_per_month"]
|
||||
docs_remaining = max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
|
||||
|
||||
return {
|
||||
"can_translate": docs_remaining != 0 or user.extra_credits > 0,
|
||||
"docs_used": user.docs_translated_this_month,
|
||||
"docs_limit": docs_limit,
|
||||
"docs_remaining": docs_remaining,
|
||||
"pages_used": user.pages_translated_this_month,
|
||||
"extra_credits": user.extra_credits,
|
||||
"max_pages_per_doc": plan["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan["max_file_size_mb"],
|
||||
"allowed_providers": plan["providers"],
|
||||
}
|
||||
|
||||
|
||||
def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> bool:
|
||||
"""Record document translation usage"""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
updates = {
|
||||
"docs_translated_this_month": user.docs_translated_this_month + 1,
|
||||
"pages_translated_this_month": user.pages_translated_this_month + pages_count,
|
||||
}
|
||||
|
||||
if use_credits:
|
||||
updates["extra_credits"] = max(0, user.extra_credits - pages_count)
|
||||
|
||||
update_user(user_id, updates)
|
||||
return True
|
||||
|
||||
|
||||
def add_credits(user_id: str, credits: int) -> bool:
|
||||
"""Add credits to a user's account"""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
update_user(user_id, {"extra_credits": user.extra_credits + credits})
|
||||
return True
|
||||
298
services/payment_service.py
Normal file
298
services/payment_service.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
Stripe payment integration for subscriptions and credits
|
||||
"""
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
# Try to import stripe
|
||||
try:
|
||||
import stripe
|
||||
STRIPE_AVAILABLE = True
|
||||
except ImportError:
|
||||
STRIPE_AVAILABLE = False
|
||||
stripe = None
|
||||
|
||||
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
|
||||
from services.auth_service import get_user_by_id, update_user, add_credits
|
||||
|
||||
|
||||
# Stripe configuration
|
||||
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||||
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
||||
|
||||
if STRIPE_AVAILABLE and STRIPE_SECRET_KEY:
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
|
||||
|
||||
def is_stripe_configured() -> bool:
|
||||
"""Check if Stripe is properly configured"""
|
||||
return STRIPE_AVAILABLE and bool(STRIPE_SECRET_KEY)
|
||||
|
||||
|
||||
async def create_checkout_session(
|
||||
user_id: str,
|
||||
plan: PlanType,
|
||||
billing_period: str = "monthly", # monthly or yearly
|
||||
success_url: str = "",
|
||||
cancel_url: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create a Stripe checkout session for subscription"""
|
||||
if not is_stripe_configured():
|
||||
return {"error": "Stripe not configured", "demo_mode": True}
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}
|
||||
|
||||
plan_config = PLANS[plan]
|
||||
price_id = plan_config[f"stripe_price_id_{billing_period}"]
|
||||
|
||||
if not price_id:
|
||||
return {"error": "Plan not available for purchase"}
|
||||
|
||||
try:
|
||||
# Create or get Stripe customer
|
||||
if user.stripe_customer_id:
|
||||
customer_id = user.stripe_customer_id
|
||||
else:
|
||||
customer = stripe.Customer.create(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
metadata={"user_id": user_id}
|
||||
)
|
||||
customer_id = customer.id
|
||||
update_user(user_id, {"stripe_customer_id": customer_id})
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
mode="subscription",
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
||||
metadata={"user_id": user_id, "plan": plan.value},
|
||||
subscription_data={
|
||||
"metadata": {"user_id": user_id, "plan": plan.value}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"url": session.url
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def create_credits_checkout(
|
||||
user_id: str,
|
||||
package_index: int,
|
||||
success_url: str = "",
|
||||
cancel_url: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Create a Stripe checkout session for credit purchase"""
|
||||
if not is_stripe_configured():
|
||||
return {"error": "Stripe not configured", "demo_mode": True}
|
||||
|
||||
if package_index < 0 or package_index >= len(CREDIT_PACKAGES):
|
||||
return {"error": "Invalid package"}
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return {"error": "User not found"}
|
||||
|
||||
package = CREDIT_PACKAGES[package_index]
|
||||
|
||||
try:
|
||||
# Create or get Stripe customer
|
||||
if user.stripe_customer_id:
|
||||
customer_id = user.stripe_customer_id
|
||||
else:
|
||||
customer = stripe.Customer.create(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
metadata={"user_id": user_id}
|
||||
)
|
||||
customer_id = customer.id
|
||||
update_user(user_id, {"stripe_customer_id": customer_id})
|
||||
|
||||
# Create checkout session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
mode="payment",
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": package["stripe_price_id"], "quantity": 1}],
|
||||
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?credits=purchased",
|
||||
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
||||
metadata={
|
||||
"user_id": user_id,
|
||||
"credits": package["credits"],
|
||||
"type": "credits"
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"url": session.url
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
|
||||
"""Handle Stripe webhook events"""
|
||||
if not is_stripe_configured():
|
||||
return {"error": "Stripe not configured"}
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
return {"error": "Invalid payload"}
|
||||
except stripe.error.SignatureVerificationError:
|
||||
return {"error": "Invalid signature"}
|
||||
|
||||
# Handle the event
|
||||
if event["type"] == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
await handle_checkout_completed(session)
|
||||
|
||||
elif event["type"] == "customer.subscription.updated":
|
||||
subscription = event["data"]["object"]
|
||||
await handle_subscription_updated(subscription)
|
||||
|
||||
elif event["type"] == "customer.subscription.deleted":
|
||||
subscription = event["data"]["object"]
|
||||
await handle_subscription_deleted(subscription)
|
||||
|
||||
elif event["type"] == "invoice.payment_failed":
|
||||
invoice = event["data"]["object"]
|
||||
await handle_payment_failed(invoice)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
async def handle_checkout_completed(session: Dict):
|
||||
"""Handle successful checkout"""
|
||||
metadata = session.get("metadata", {})
|
||||
user_id = metadata.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
# Check if it's a credit purchase
|
||||
if metadata.get("type") == "credits":
|
||||
credits = int(metadata.get("credits", 0))
|
||||
add_credits(user_id, credits)
|
||||
return
|
||||
|
||||
# It's a subscription
|
||||
plan = metadata.get("plan")
|
||||
if plan:
|
||||
subscription_id = session.get("subscription")
|
||||
update_user(user_id, {
|
||||
"plan": plan,
|
||||
"subscription_status": SubscriptionStatus.ACTIVE.value,
|
||||
"stripe_subscription_id": subscription_id,
|
||||
"docs_translated_this_month": 0, # Reset on new subscription
|
||||
"pages_translated_this_month": 0,
|
||||
})
|
||||
|
||||
|
||||
async def handle_subscription_updated(subscription: Dict):
|
||||
"""Handle subscription updates"""
|
||||
metadata = subscription.get("metadata", {})
|
||||
user_id = metadata.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
status_map = {
|
||||
"active": SubscriptionStatus.ACTIVE,
|
||||
"past_due": SubscriptionStatus.PAST_DUE,
|
||||
"canceled": SubscriptionStatus.CANCELED,
|
||||
"trialing": SubscriptionStatus.TRIALING,
|
||||
"paused": SubscriptionStatus.PAUSED,
|
||||
}
|
||||
|
||||
stripe_status = subscription.get("status", "active")
|
||||
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
|
||||
|
||||
update_user(user_id, {
|
||||
"subscription_status": status.value,
|
||||
"subscription_ends_at": datetime.fromtimestamp(
|
||||
subscription.get("current_period_end", 0)
|
||||
).isoformat() if subscription.get("current_period_end") else None
|
||||
})
|
||||
|
||||
|
||||
async def handle_subscription_deleted(subscription: Dict):
|
||||
"""Handle subscription cancellation"""
|
||||
metadata = subscription.get("metadata", {})
|
||||
user_id = metadata.get("user_id")
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
update_user(user_id, {
|
||||
"plan": PlanType.FREE.value,
|
||||
"subscription_status": SubscriptionStatus.CANCELED.value,
|
||||
"stripe_subscription_id": None,
|
||||
})
|
||||
|
||||
|
||||
async def handle_payment_failed(invoice: Dict):
|
||||
"""Handle failed payment"""
|
||||
customer_id = invoice.get("customer")
|
||||
if not customer_id:
|
||||
return
|
||||
|
||||
# Find user by customer ID and update status
|
||||
# In production, query database by stripe_customer_id
|
||||
|
||||
|
||||
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
|
||||
"""Cancel a user's subscription"""
|
||||
if not is_stripe_configured():
|
||||
return {"error": "Stripe not configured"}
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user or not user.stripe_subscription_id:
|
||||
return {"error": "No active subscription"}
|
||||
|
||||
try:
|
||||
# Cancel at period end
|
||||
subscription = stripe.Subscription.modify(
|
||||
user.stripe_subscription_id,
|
||||
cancel_at_period_end=True
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "canceling",
|
||||
"cancel_at": datetime.fromtimestamp(subscription.cancel_at).isoformat() if subscription.cancel_at else None
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def get_billing_portal_url(user_id: str) -> Optional[str]:
|
||||
"""Get Stripe billing portal URL for customer"""
|
||||
if not is_stripe_configured():
|
||||
return None
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if not user or not user.stripe_customer_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=user.stripe_customer_id,
|
||||
return_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard"
|
||||
)
|
||||
return session.url
|
||||
except:
|
||||
return None
|
||||
Reference in New Issue
Block a user