feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
10
.gitattributes
vendored
Normal file
10
.gitattributes
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Shell scripts must always use LF line endings
|
||||
*.sh text eol=lf
|
||||
|
||||
# Windows scripts
|
||||
*.ps1 text eol=crlm
|
||||
*.bat text eol=crlm
|
||||
*.cmd text eol=crlm
|
||||
|
||||
# Auto-detect for all other text files
|
||||
* text=auto
|
||||
565
DEPLOYMENT_HOMELAB.md
Normal file
565
DEPLOYMENT_HOMELAB.md
Normal file
@@ -0,0 +1,565 @@
|
||||
# Guide de Deploiement - Wordly.art (Homelab)
|
||||
> Nginx Proxy Manager + IONOS DNS + Docker + NAS Backup + Monitoring
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
| DNS IONOS: wordly.art -> ton IP fixe
|
||||
|
|
||||
[Routeur/Box] (port forwarding 80+443 -> machine NPM)
|
||||
|
|
||||
+-- Machine 1: Nginx Proxy Manager (NPM)
|
||||
| - SSL Let's Encrypt automatique
|
||||
| - Reverse proxy vers les services
|
||||
|
|
||||
+-- Machine 2 ou 3: Docker (Wordly app)
|
||||
|
|
||||
+-- wordly-backend (FastAPI :8000)
|
||||
+-- wordly-frontend (Next.js :3000)
|
||||
+-- wordly-postgres (PostgreSQL :5432)
|
||||
+-- wordly-redis (Redis :6379)
|
||||
+-- wordly-prometheus (interne :9090)
|
||||
+-- wordly-grafana (:3001)
|
||||
+-- wordly-node-exporter
|
||||
+-- wordly-cadvisor
|
||||
|
|
||||
+-- Backup quotidien -> NAS (SMB/NFS)
|
||||
```
|
||||
|
||||
### Routing NPM
|
||||
|
||||
| Sous-domaine | Cible Docker | Port |
|
||||
|--------------|-------------|------|
|
||||
| `wordly.art` | `wordly-frontend` | 3000 |
|
||||
| `wordly.art/api/*` | `wordly-backend` | 8000 |
|
||||
| `wordly.art/translate` | `wordly-backend` | 8000 |
|
||||
| `monitoring.wordly.art` | `wordly-grafana` | 3000 |
|
||||
|
||||
---
|
||||
|
||||
## Etape 1 : Configuration DNS chez IONOS
|
||||
|
||||
### 1.1 Connexion a IONOS
|
||||
1. Se connecter sur **ionos.fr** / **ionos.com**
|
||||
2. Aller dans **Domaines & SSL**
|
||||
3. Cliquer sur **wordly.art**
|
||||
|
||||
### 1.2 Creer les enregistrements DNS
|
||||
|
||||
Cliquer sur **DNS** > **Gerer les enregistrements** et ajouter :
|
||||
|
||||
| Type | Nom | Valeur | TTL |
|
||||
|------|-----|--------|-----|
|
||||
| **A** | `@` | `TON_IP_FIXE` | 3600 |
|
||||
| **A** | `www` | `TON_IP_FIXE` | 3600 |
|
||||
| **A** | `monitoring` | `TON_IP_FIXE` | 3600 |
|
||||
|
||||
> Remplace `TON_IP_FIXE` par ton IP fixe publique.
|
||||
> Pour la trouver : https://whatismyip.com
|
||||
|
||||
### 1.3 Verifier la propagation DNS
|
||||
```bash
|
||||
# Attendre 5-30 minutes puis verifier
|
||||
nslookup wordly.art
|
||||
nslookup monitoring.wordly.art
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Etape 2 : Nginx Proxy Manager (NPM)
|
||||
|
||||
### 2.1 Verifier que NPM tourne
|
||||
NPM est deja installe sur une de tes machines. Verifier :
|
||||
|
||||
```bash
|
||||
# Sur la machine qui heberge NPM
|
||||
docker ps | grep npm
|
||||
# ou
|
||||
docker ps | grep nginx-proxy-manager
|
||||
```
|
||||
|
||||
L'interface admin NPM est accessible sur **http://IP_NPM:81**
|
||||
|
||||
### 2.2 Connecter NPM au reseau Docker de Wordly
|
||||
|
||||
Pour que NPM puisse atteindre les containers Wordly par leur nom, il doit etre sur le meme reseau Docker.
|
||||
|
||||
```bash
|
||||
# Sur la machine qui heberge NPM, trouver le nom du container NPM
|
||||
docker ps | grep -i npm
|
||||
|
||||
# Trouver le reseau du container NPM
|
||||
docker inspect <NPM_CONTAINER> | grep -A5 Networks
|
||||
|
||||
# Sur la machine Wordly, on va connecter NPM au reseau wordly-network
|
||||
# (uniquement si NPM et Wordly sont sur la MEME machine Docker)
|
||||
docker network connect wordly-network <NPM_CONTAINER>
|
||||
```
|
||||
|
||||
**Si NPM et Wordly sont sur des machines differentes** (recommande avec 3 machines) :
|
||||
- Pas besoin de reseau Docker partage
|
||||
- NPM utilisera l'IP de la machine Wordly au lieu du nom de container
|
||||
- Voir section 2.4 pour la configuration
|
||||
|
||||
### 2.3 Creer les Proxy Hosts dans NPM
|
||||
|
||||
Se connecter a **http://IP_NPM:81** puis :
|
||||
|
||||
#### Proxy Host 1 : wordly.art (Frontend + Backend)
|
||||
|
||||
Aller dans **Proxy Hosts** > **Add Proxy Host** :
|
||||
|
||||
**Onglet Details :**
|
||||
- **Domain Names** : `wordly.art`
|
||||
- **Scheme** : `http`
|
||||
- **Forward Hostname/IP** : `wordly-frontend` *(si meme machine)* OU `IP_MACHINE_WORDLY` *(si machines differentes)*
|
||||
- **Forward Port** : `3000`
|
||||
- Cocher **Block Common Exploits**
|
||||
- Cocher **Websockets Support**
|
||||
|
||||
**Onglet SSL :**
|
||||
- **SSL Certificate** : `Request a new SSL Certificate`
|
||||
- Cocher **Force SSL**
|
||||
- Cocher **HTTP/2 Support**
|
||||
- Cocher **HSTS Enabled**
|
||||
- **Email** : `admin@wordly.art`
|
||||
- Cocher **I Agree to the...**
|
||||
|
||||
Cliquer **Save**. NPM va automatiquement obtenir le certificat Let's Encrypt.
|
||||
|
||||
#### Proxy Host 2 : www.wordly.art -> redirect
|
||||
|
||||
**Onglet Details :**
|
||||
- **Domain Names** : `www.wordly.art`
|
||||
- **Scheme** : `http`
|
||||
- **Forward Hostname/IP** : `wordly-frontend`
|
||||
- **Forward Port** : `3000`
|
||||
|
||||
**Onglet SSL :**
|
||||
- Meme certificat que wordly.art (selectionner le certificat deja cree)
|
||||
|
||||
**Onglet Advanced :**
|
||||
Ajouter dans le champ Custom Nginx Configuration :
|
||||
|
||||
```nginx
|
||||
# Rediriger toutes les requetes API et translate vers le backend
|
||||
location /api/ {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
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;
|
||||
|
||||
add_header Access-Control-Allow-Origin https://wordly.art 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, X-API-Key" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
location /translate {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
location /docs {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://wordly-backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /admin {
|
||||
proxy_pass http://wordly-frontend:3000;
|
||||
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;
|
||||
}
|
||||
|
||||
location /_next/static/ {
|
||||
proxy_pass http://wordly-frontend:3000;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
> **IMPORTANT** : Si NPM et Wordly sont sur des machines differentes, remplacer
|
||||
> `wordly-backend:8000` par `IP_MACHINE_WORDLY:8000` et
|
||||
> `wordly-frontend:3000` par `IP_MACHINE_WORDLY:3000`
|
||||
|
||||
#### Proxy Host 3 : monitoring.wordly.art (Grafana)
|
||||
|
||||
**Onglet Details :**
|
||||
- **Domain Names** : `monitoring.wordly.art`
|
||||
- **Scheme** : `http`
|
||||
- **Forward Hostname/IP** : `wordly-grafana` *(si meme machine)* OU `IP_MACHINE_WORDLY` *(si machines differentes)*
|
||||
- **Forward Port** : `3000`
|
||||
- Cocher **Block Common Exploits**
|
||||
|
||||
**Onglet SSL :**
|
||||
- **SSL Certificate** : `Request a new SSL Certificate`
|
||||
- Cocher **Force SSL**
|
||||
- Cocher **HSTS Enabled**
|
||||
- **Email** : `admin@wordly.art`
|
||||
|
||||
### 2.4 Cas : NPM et Wordly sur machines differentes
|
||||
|
||||
Si NPM tourne sur Machine A et Wordly sur Machine B :
|
||||
|
||||
1. **Ouvrir les ports** sur la Machine B pour que NPM puisse atteindre les services :
|
||||
```bash
|
||||
# Sur Machine B, ouvrir dans le pare-feu local
|
||||
sudo ufw allow from IP_MACHINE_A to any port 3000 # Frontend
|
||||
sudo ufw allow from IP_MACHINE_A to any port 8000 # Backend
|
||||
sudo ufw allow from IP_MACHINE_A to any port 3001 # Grafana
|
||||
```
|
||||
|
||||
2. **Exposer les ports** dans docker-compose.yml en ajoutant la section `ports` :
|
||||
```yaml
|
||||
backend:
|
||||
# ... config existante ...
|
||||
ports:
|
||||
- "IP_MACHINE_B:8000:8000" # Bind sur IP locale seulement
|
||||
|
||||
frontend:
|
||||
# ... config existante ...
|
||||
ports:
|
||||
- "IP_MACHINE_B:3000:3000"
|
||||
|
||||
grafana:
|
||||
# ... config existante ...
|
||||
ports:
|
||||
- "IP_MACHINE_B:3000:3000"
|
||||
```
|
||||
|
||||
3. Dans NPM, utiliser `IP_MACHINE_B` comme Forward Hostname
|
||||
|
||||
---
|
||||
|
||||
## Etape 3 : Deploiement de l'application
|
||||
|
||||
### 3.1 Preparer le serveur
|
||||
|
||||
```bash
|
||||
# Installer Docker (si pas deja fait)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Verifier
|
||||
docker --version
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### 3.2 Transferer le code
|
||||
|
||||
```bash
|
||||
# Cloner le repo
|
||||
git clone <ton-repo-git> /opt/wordly
|
||||
cd /opt/wordly
|
||||
git checkout production-deployment
|
||||
```
|
||||
|
||||
### 3.3 Configurer les secrets
|
||||
|
||||
```bash
|
||||
cd /opt/wordly
|
||||
|
||||
# Copier le fichier env
|
||||
cp .env.production .env
|
||||
|
||||
# Generer le hash bcrypt du mot de passe admin
|
||||
docker run --rm python:3.12-slim bash -c "
|
||||
pip install 'passlib[bcrypt]' bcrypt > /dev/null 2>&1 &&
|
||||
python -c \"from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('TON_MOT_DE_PASSE_ADMIN'))\"
|
||||
"
|
||||
```
|
||||
|
||||
Copier le hash affiche et le coller dans `.env` pour `ADMIN_PASSWORD_HASH`.
|
||||
|
||||
```bash
|
||||
# Editer le fichier .env et verifier tous les champs
|
||||
nano .env
|
||||
```
|
||||
|
||||
Verifications cles dans `.env` :
|
||||
- `DOMAIN=wordly.art`
|
||||
- `NEXT_PUBLIC_API_URL=https://wordly.art`
|
||||
- `JWT_SECRET_KEY=` (deja rempli)
|
||||
- `ADMIN_TOKEN_SECRET=` (deja rempli)
|
||||
- `ADMIN_PASSWORD_HASH=` (coller le hash genere)
|
||||
- `CORS_ORIGINS=https://wordly.art`
|
||||
- `POSTGRES_PASSWORD=` (deja rempli)
|
||||
|
||||
### 3.4 Lancer l'application
|
||||
|
||||
```bash
|
||||
# Build et demarrage
|
||||
docker compose up -d --build
|
||||
|
||||
# Suivre les logs pendant le premier demarrage
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 3.5 Verifier
|
||||
|
||||
```bash
|
||||
# Sur le serveur, tester directement
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Verifier tous les containers
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Resultat attendu :
|
||||
```
|
||||
NAME STATUS PORTS
|
||||
wordly-postgres Up (healthy)
|
||||
wordly-redis Up (healthy)
|
||||
wordly-backend Up (healthy)
|
||||
wordly-frontend Up (healthy)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Etape 4 : Verification complete
|
||||
|
||||
### 4.1 Tester depuis l'exterieur
|
||||
|
||||
```bash
|
||||
# Depuis un autre ordinateur ou telephone
|
||||
curl -I https://wordly.art
|
||||
|
||||
# Doit retourner:
|
||||
# HTTP/2 200
|
||||
# server: nginx
|
||||
# strict-transport-security: ...
|
||||
```
|
||||
|
||||
### 4.2 Tester dans le navigateur
|
||||
|
||||
1. Ouvrir **https://wordly.art** -> doit afficher le frontend
|
||||
2. Ouvrir **https://wordly.art/health** -> doit retourner `{"status": "ok"}`
|
||||
3. Ouvrir **https://wordly.art/admin** -> doit afficher le login admin
|
||||
4. Ouvrir **https://monitoring.wordly.art** -> doit afficher Grafana
|
||||
|
||||
### 4.3 Tester le SSL
|
||||
|
||||
Le cadenas vert doit etre present sur wordly.art. NPM a automatiquement obtenu le certificat Let's Encrypt.
|
||||
|
||||
---
|
||||
|
||||
## Etape 5 : Backup automatique vers NAS
|
||||
|
||||
### 5.1 Monter le NAS
|
||||
|
||||
#### Option A : SMB/CIFS (Synology / QNAP)
|
||||
```bash
|
||||
sudo apt install cifs-utils
|
||||
sudo mkdir -p /mnt/nas-backups/wordly
|
||||
|
||||
# Fichier credentials
|
||||
sudo tee /etc/nas-credentials <<EOF
|
||||
username=wordly-backup
|
||||
password=MOT_DE_PASSE_NAS
|
||||
domain=WORKGROUP
|
||||
EOF
|
||||
sudo chmod 600 /etc/nas-credentials
|
||||
|
||||
# Montage auto au demarrage
|
||||
echo "//IP_DU_NAS/wordly-backups /mnt/nas-backups/wordly cifs credentials=/etc/nas-credentials,uid=$(id -u),gid=$(id -g),iocharset=utf8,vers=3.0,noperm 0 0" | sudo tee -a /etc/fstab
|
||||
|
||||
sudo mount /mnt/nas-backups/wordly
|
||||
```
|
||||
|
||||
#### Option B : NFS
|
||||
```bash
|
||||
sudo apt install nfs-common
|
||||
sudo mkdir -p /mnt/nas-backups/wordly
|
||||
echo "IP_DU_NAS:/volume1/wordly-backups /mnt/nas-backups/wordly nfs rw,hard,intr 0 0" | sudo tee -a /etc/fstab
|
||||
sudo mount /mnt/nas-backups/wordly
|
||||
```
|
||||
|
||||
### 5.2 Tester le backup
|
||||
```bash
|
||||
chmod +x /opt/wordly/scripts/backup-to-nas.sh
|
||||
/opt/wordly/scripts/backup-to-nas.sh --full
|
||||
|
||||
# Verifier
|
||||
ls -lh /mnt/nas-backups/wordly/daily/
|
||||
```
|
||||
|
||||
### 5.3 Programmer le cron
|
||||
```bash
|
||||
crontab -e
|
||||
|
||||
# Backup quotidien a 3h du matin
|
||||
0 3 * * * /opt/wordly/scripts/backup-to-nas.sh >> /var/log/wordly-backup.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Etape 6 : Monitoring (Prometheus + Grafana)
|
||||
|
||||
### 6.1 Lancer le stack monitoring
|
||||
```bash
|
||||
cd /opt/wordly
|
||||
|
||||
# Creer le reseau externe si necessaire
|
||||
docker network create wordly-network 2>/dev/null || true
|
||||
|
||||
# Lancer application + monitoring
|
||||
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
### 6.2 Acceder a Grafana
|
||||
|
||||
- **URL** : `https://monitoring.wordly.art` (via NPM)
|
||||
- **Login** : `admin`
|
||||
- **Mot de passe** : `WordlyGrafana2026!`
|
||||
- Changer le mot de passe a la premiere connexion
|
||||
|
||||
### 6.3 Dashboards pre-configures
|
||||
|
||||
| Dashboard | Contenu |
|
||||
|-----------|---------|
|
||||
| **Wordly - Application** | Traductions, latence, providers, taux d'erreur |
|
||||
| **Wordly - Infrastructure** | CPU, RAM, disque, reseau, status containers |
|
||||
|
||||
Ils apparaissent automatiquement dans le dossier **Wordly** de Grafana.
|
||||
|
||||
### 6.4 Configurer les alertes (optionnel)
|
||||
|
||||
Dans Grafana : **Alerting** > **Contact points** > **Add contact point** :
|
||||
- Type : **Email** ou **Discord webhook**
|
||||
- Ajouter ton email ou l'URL du webhook
|
||||
|
||||
Les regles d'alerte suivantes sont pre-configures :
|
||||
- Backend down
|
||||
- Taux d'erreur > 10%
|
||||
- RAM > 90%
|
||||
- Disque < 15%
|
||||
|
||||
---
|
||||
|
||||
## Etape 7 : Operations courantes
|
||||
|
||||
### Logs
|
||||
```bash
|
||||
docker compose logs -f --tail=100 # Tous les services
|
||||
docker compose logs -f backend # Backend seul
|
||||
docker compose logs -f frontend # Frontend seul
|
||||
docker compose logs -f postgres # Base de donnees
|
||||
```
|
||||
|
||||
### Redemarrer
|
||||
```bash
|
||||
docker compose restart backend
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
### Mettre a jour
|
||||
```bash
|
||||
cd /opt/wordly
|
||||
git pull origin production-deployment
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### Restaurer un backup
|
||||
```bash
|
||||
/opt/wordly/scripts/backup-to-nas.sh --restore
|
||||
# Liste les backups disponibles
|
||||
|
||||
/opt/wordly/scripts/backup-to-nas.sh --restore wordly_db_20260510_030000.sql.gz
|
||||
```
|
||||
|
||||
### Verifier l'espace
|
||||
```bash
|
||||
df -h # Disque systeme
|
||||
docker system df # Espace Docker
|
||||
```
|
||||
|
||||
### Renouveler le SSL
|
||||
NPM renouvelle les certificats Let's Encrypt automatiquement. Pas d'action requise.
|
||||
Verifier dans NPM > SSL Certificates que le statut est OK.
|
||||
|
||||
---
|
||||
|
||||
## Checklist de deploiement
|
||||
|
||||
### DNS IONOS
|
||||
- [ ] Enregistrement A : `@` -> IP fixe
|
||||
- [ ] Enregistrement A : `www` -> IP fixe
|
||||
- [ ] Enregistrement A : `monitoring` -> IP fixe
|
||||
- [ ] Propagation DNS verifiee (nslookup)
|
||||
|
||||
### Nginx Proxy Manager
|
||||
- [ ] Proxy Host cree pour `wordly.art` -> frontend:3000
|
||||
- [ ] SSL Let's Encrypt obtenu pour `wordly.art`
|
||||
- [ ] Custom config nginx ajoutee (routing API/backend)
|
||||
- [ ] Proxy Host cree pour `monitoring.wordly.art` -> grafana:3000
|
||||
- [ ] SSL Let's Encrypt obtenu pour `monitoring.wordly.art`
|
||||
|
||||
### Serveur Docker
|
||||
- [ ] Docker installe
|
||||
- [ ] Code clone dans /opt/wordly
|
||||
- [ ] Fichier .env rempli avec tous les secrets
|
||||
- [ ] Hash bcrypt genere et colle
|
||||
- [ ] `docker compose up -d --build` reussi
|
||||
- [ ] Tous les containers healthy
|
||||
|
||||
### NAS
|
||||
- [ ] Partage de backup cree sur le NAS
|
||||
- [ ] NAS monte sur /mnt/nas-backups/wordly
|
||||
- [ ] Backup manuel test OK
|
||||
- [ ] Cron programme a 3h
|
||||
|
||||
### Verification finale
|
||||
- [ ] https://wordly.art accessible depuis Internet
|
||||
- [ ] HTTPS OK (cadenas vert)
|
||||
- [ ] Upload fichier OK
|
||||
- [ ] Traduction test OK
|
||||
- [ ] https://monitoring.wordly.art accessible
|
||||
- [ ] Dashboards Grafana affichent des donnees
|
||||
- [ ] Page admin accessible (/admin)
|
||||
212
MARKETING_PLAN.md
Normal file
212
MARKETING_PLAN.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# Plan Marketing - Office Translator (SaaS de Traduction de Documents)
|
||||
|
||||
> Document de référence pour l'agent marketing. Dernière mise à jour : 2026-05-10
|
||||
|
||||
---
|
||||
|
||||
## 1. Positionnement & Proposition de Valeur
|
||||
|
||||
### Le Produit
|
||||
**Office Translator** est un service SaaS de traduction de documents professionnels (Word, Excel, PowerPoint) qui **préserve parfaitement la mise en forme, les tableaux, les images et les styles** du document original.
|
||||
|
||||
### Ce qui nous différencie
|
||||
- **Préservation du format** : Contrairement à Google Translate ou DeepL qui détruisent les layouts complexes, notre moteur maintient la structure exacte
|
||||
- **Multi-providers** : L'utilisateur choisit son moteur (Google, DeepL, OpenAI, Ollama/local) selon son budget et ses besoins
|
||||
- **Glossaires techniques** : Terminologie personnalisée (HVAC, IT, Juridique, Médical)
|
||||
- **Self-hostable** : Peut être déployé en privé pour les entreprises soucieuses de confidentialité
|
||||
|
||||
### Marché cible
|
||||
| Segment | Taille estimée | Priorité |
|
||||
|---------|---------------|----------|
|
||||
| PME internationales ( traductions régulières) | Grand | P1 |
|
||||
| Agences de traduction (productivité) | Moyen | P1 |
|
||||
| Départements RH multilingues | Moyen | P2 |
|
||||
| Freelancers / consultants | Grand | P2 |
|
||||
| Étudiants & academics | Grand | P3 (freemium) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Supports Visuels Requis
|
||||
|
||||
### A. Captures d'écran (OBLIGATOIRES - priorité maximale)
|
||||
|
||||
| # | Capture | Usage | Instructions |
|
||||
|---|---------|-------|-------------|
|
||||
| 1 | **Page d'accueil / Hero** | Landing page, réseaux sociaux | Montrer l'interface épurée avec le drop-zone de fichier |
|
||||
| 2 | **Upload en cours** | Démonstration du workflow | Fichier Excel chargé avec sélection langue source/cible |
|
||||
| 3 | **Résultat côte à côte** | Preuve de qualité | Document original vs traduit, montrer que le format est intact |
|
||||
| 4 | **Sélection du provider** | Fonctionnalité clé | Dropdown Google/DeepL/OpenAI/Ollama avec prix affichés |
|
||||
| 5 | **Glossaire technique** | Différenciation | Interface de gestion des glossaires personnalisés |
|
||||
| 6 | **Dashboard admin** | Crédibilité entreprise | Vue monitoring avec statistiques d'utilisation |
|
||||
| 7 | **Page pricing/forfaits** | Conversion | Les 3 tiers (Starter/Pro/Business) clairement affichés |
|
||||
| 8 | **Profil utilisateur** | Confiance | Page de profil avec historique et quota |
|
||||
|
||||
**Format** : PNG, 1280x720 minimum, fond clair et sombre
|
||||
|
||||
### B. Vidéo de Démonstration (OBLIGATOIRE)
|
||||
|
||||
#### Vidéo courte (60-90 secondes) - "How it works"
|
||||
- **Objectif** : Landing page + réseaux sociaux
|
||||
- **Script suggéré** :
|
||||
1. (0-5s) Logo + tagline animé
|
||||
2. (5-15s) Problème : "Traduire un Excel de 50 pages sans casser le format ? Mission impossible."
|
||||
3. (15-40s) Démo accélérée : Upload → Sélection langue → Provider → Traduction → Download
|
||||
4. (40-55s) Split-screen : document original vs traduit, zoom sur tableaux/images intacts
|
||||
5. (55-65s) CTA : "Essayez gratuitement" + URL
|
||||
- **Format** : MP4 1080p, sous-titres FR + EN
|
||||
|
||||
#### Vidéo tutoriel (3-5 minutes) - "Guide complet"
|
||||
- **Objectif** : YouTube, onboarding utilisateurs
|
||||
- **Contenu** : Création de compte, upload, glossaires, providers, download
|
||||
- **Format** : Screencast avec voiceover
|
||||
|
||||
### C. Autres assets visuels
|
||||
|
||||
| Asset | Spécifications |
|
||||
|-------|---------------|
|
||||
| **Logo SVG** | Version claire + sombre, icône seule + avec texte |
|
||||
| **OG Image** | 1200x630px pour partage réseaux sociaux |
|
||||
| **Favicon** | 32x32, 16x16, ICO + PNG |
|
||||
| **Bannière GitHub** | 1280x640px pour le repo README |
|
||||
| **GIF animé** | 15s loop du workflow upload→traduction→download |
|
||||
| **Infographie** | "Pourquoi Office Translator" - comparaison avant/après |
|
||||
|
||||
---
|
||||
|
||||
## 3. Canaux de Lancement
|
||||
|
||||
### Phase 1 : Pré-lancement (Semaines 1-2)
|
||||
|
||||
| Canal | Action | Priorité |
|
||||
|-------|--------|----------|
|
||||
| **Landing page** | Mettre en place avec captures d'écran + vidéo + formulaire email | CRITIQUE |
|
||||
| **Product Hunt** | Préparer le launch (assets, description, maker comment) | HAUTE |
|
||||
| **Reddit** | Posts dans r/SideProject, r/selfhosted, r/translator | HAUTE |
|
||||
| **Hacker News** | Préparer un "Show HN" technique | HAUTE |
|
||||
| **Twitter/X** | Thread de lancement avec démo GIF | MOYENNE |
|
||||
| **LinkedIn** | Post professionnel ciblant PME et agences | MOYENNE |
|
||||
|
||||
### Phase 2 : Lancement (Semaine 3)
|
||||
|
||||
| Canal | Action |
|
||||
|-------|--------|
|
||||
| **Product Hunt** | Lancement le mardi ou mercredi (meilleur trafic) |
|
||||
| **Reddit** | Cross-post dans 5-6 subreddits pertinents |
|
||||
| **Hacker News** | Soumettre le Show HN le matin (heure EST) |
|
||||
| **Twitter/X** | Thread avec GIF + link Product Hunt |
|
||||
| **IndieHackers** | Post détaillé sur le build process |
|
||||
| **Dev.to** | Article technique sur l'architecture |
|
||||
| **Twitter/X (communautés)** | Cibler #BuildInPublic, #SaaS, #i18n |
|
||||
|
||||
### Phase 3 : Croissance (Semaines 4-8)
|
||||
|
||||
| Canal | Action |
|
||||
|-------|--------|
|
||||
| **SEO** | Articles de blog : "Comment traduire un Excel sans perdre le format", etc. |
|
||||
| **YouTube** | Tutoriels et reviews |
|
||||
| **Partenariats** | Agences de traduction, consultants internationaux |
|
||||
| **Google Ads** | Mots-clés "translate excel document", "translate powerpoint" |
|
||||
| **Communautés** | Discord/Slack de développeurs et traducteurs |
|
||||
| **AppSumo** | Liste Lifetime Deal pour traction initiale |
|
||||
|
||||
---
|
||||
|
||||
## 4. Stratégie de Contenu
|
||||
|
||||
### Articles de Blog (SEO) - Minimum 5 au lancement
|
||||
|
||||
1. **"Comment traduire un fichier Excel sans perdre la mise en forme"**
|
||||
2. **"Les 5 meilleurs outils de traduction de documents comparés (2026)"**
|
||||
3. **"Traduction professionnelle : guide complet pour les PME"**
|
||||
4. **"Pourquoi DeepL et Google Translate détruisent vos documents Excel"**
|
||||
5. **"Auto-héberger son outil de traduction : guide complet"**
|
||||
|
||||
### Contenu Réseaux Sociaux (Répétitif)
|
||||
|
||||
| Type | Fréquence | Plateforme |
|
||||
|------|-----------|-----------|
|
||||
| Astuce traduction | 2x/semaine | Twitter, LinkedIn |
|
||||
| Before/After document | 1x/semaine | Twitter, Instagram |
|
||||
| Thread technique | 1x/2 semaines | Twitter |
|
||||
| Témoignage client | Quand disponible | Tous |
|
||||
| Mise à jour produit | Selon releases | Tous |
|
||||
|
||||
---
|
||||
|
||||
## 5. Stratégie Tarifaire & Positionnement Prix
|
||||
|
||||
### Forfaits actuels (à communiquer)
|
||||
|
||||
| Plan | Prix | Public cible | Message clé |
|
||||
|------|------|-------------|-------------|
|
||||
| **Starter** | Prix entry-level | Freelancers, étudiants | "Testez sans risque" |
|
||||
| **Pro** | Prix milieu | PME, consultants | "Le meilleur rapport qualité-prix" |
|
||||
| **Business** | Prix premium | Agences, entreprises | "Volume illimité + support dédié" |
|
||||
|
||||
### Message tarifaire
|
||||
- Insister sur le **coût par page** vs traduction humaine (généralement 50-100x moins cher)
|
||||
- Mettre en avant le **freemium** ou l'**essai gratuit** si disponible
|
||||
- Comparer avec les coûts des solutions concurrentes
|
||||
|
||||
---
|
||||
|
||||
## 6. KPIs à Suivre
|
||||
|
||||
| Métrique | Objectif Mois 1 | Objectif Mois 3 |
|
||||
|----------|----------------|-----------------|
|
||||
| Visiteurs uniques | 5,000 | 25,000 |
|
||||
| Inscriptions | 200 | 1,500 |
|
||||
| Documents traduits | 500 | 5,000 |
|
||||
| Taux de conversion | 2% | 4% |
|
||||
| NPS | > 40 | > 50 |
|
||||
| Revenue mensuel | Variable | Variable |
|
||||
|
||||
---
|
||||
|
||||
## 7. Plan d'Action pour l'Agent Marketing
|
||||
|
||||
### Checklist Exécutable
|
||||
|
||||
- [ ] **Captures d'écran** : Réaliser les 8 captures listées en section 2A
|
||||
- [ ] **Vidéo courte** : Produire la démo 60-90s (section 2B)
|
||||
- [ ] **Vidéo tutoriel** : Produire le screencast 3-5 min
|
||||
- [ ] **Landing page** : Concevoir et publier avec tous les assets
|
||||
- [ ] **Product Hunt** : Préparer le listing complet
|
||||
- [ ] **Reddit posts** : Rédiger 5 posts adaptés par subreddit
|
||||
- [ ] **Show HN** : Écrire la soumission Hacker News
|
||||
- [ ] **Twitter thread** : Préparer le thread de lancement (10-15 tweets)
|
||||
- [ ] **Articles SEO** : Rédiger les 5 articles de blog
|
||||
- [ ] **OG Image** : Créer l'image de partage réseaux sociaux
|
||||
- [ ] **GIF animé** : Créer le loop de 15s du workflow
|
||||
- [ ] **Infographie comparatif** : Créer le visuel avant/après
|
||||
- [ ] **Setup analytics** : Google Analytics + Mixpanel/PostHog
|
||||
- [ ] **Email sequence** : 5 emails onboarding post-inscription
|
||||
- [ ] **FAQ page** : Répondre aux 10 questions les plus fréquentes
|
||||
|
||||
---
|
||||
|
||||
## 8. Concurrence Directe
|
||||
|
||||
| Concurrent | Força | Faiblesse | Notre avantage |
|
||||
|-----------|-------|-----------|---------------|
|
||||
| **Google Translate (docs)** | Gratuit, connu | Détruit les formats complexes | Préservation du format |
|
||||
| **DeepL (docs)** | Qualité de traduction | Cher, formatage limité | Multi-provider + prix |
|
||||
| **DocTranslator** | Simple | Qualité inégale, publicité | Interface pro + glossaires |
|
||||
| **Smartcat** | Complet | Complexe, cher | Simplicité + self-hostable |
|
||||
| **Transifex** | Enterprise | Trop cher pour PME | Prix accessible |
|
||||
|
||||
---
|
||||
|
||||
## 9. Timeline Recommandée
|
||||
|
||||
```
|
||||
Semaine 1-2 : Production des assets (captures, vidéos, images)
|
||||
Semaine 3 : Lancement sur Product Hunt + Reddit + HN + Twitter
|
||||
Semaine 4 : Articles SEO + contenu evergreen
|
||||
Semaine 5-6 : Partenariats + communautés + Google Ads
|
||||
Semaine 7-8 : Optimisation basée sur les premiers retours
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Ce document doit être mis à jour au fur et à mesure des retours utilisateur et des métriques observées.*
|
||||
31
alembic/versions/006_fix_tier_check_constraint.py
Normal file
31
alembic/versions/006_fix_tier_check_constraint.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Fix tier CHECK constraint to allow all plan tiers
|
||||
|
||||
Revision ID: 006
|
||||
Revises: cb71a958ad92
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "006"
|
||||
down_revision = "a1b2c3d4e5f6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
||||
"CHECK (tier IN ('free', 'starter', 'pro', 'business', 'enterprise'))"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_tier"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE users ADD CONSTRAINT ck_users_tier "
|
||||
"CHECK (tier IN ('free', 'pro'))"
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class Config:
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
# Supported file types
|
||||
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
|
||||
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx", ".pdf"}
|
||||
|
||||
# ============== Rate Limiting (SaaS) ==============
|
||||
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"
|
||||
|
||||
@@ -100,7 +100,7 @@ class User(Base):
|
||||
api_keys = relationship("ApiKey", back_populates="user", lazy="select")
|
||||
|
||||
__table_args__ = (
|
||||
CheckConstraint("tier IN ('free', 'pro')", name="ck_users_tier"),
|
||||
CheckConstraint("tier IN ('free', 'starter', 'pro', 'business', 'enterprise')", name="ck_users_tier"),
|
||||
Index("ix_users_email_active", "email", "is_active"),
|
||||
Index("ix_users_stripe_customer", "stripe_customer_id"),
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
image: redis:7-alpine
|
||||
container_name: translate-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru --loglevel warning
|
||||
command: redis-server --appendonly yes --appendfsync everysec --maxmemory 256mb --maxmemory-policy allkeys-lru --loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
|
||||
146
docker-compose.monitoring.yml
Normal file
146
docker-compose.monitoring.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
# ============================================
|
||||
# Wordly.art - Monitoring Stack (Prometheus + Grafana)
|
||||
# ============================================
|
||||
# Deploys alongside the main docker-compose.yml
|
||||
# Grafana accessible via NPM sur monitoring.wordly.art (ou IP:3001)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
#
|
||||
# Access:
|
||||
# Grafana: http://<IP_SERVEUR>:3001 (admin / WordlyGrafana2026!)
|
||||
# Ou via NPM: monitoring.wordly.art -> wordly-grafana:3000
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# Prometheus - Metrics Collection
|
||||
# ===========================================
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.52.0
|
||||
container_name: wordly-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./docker/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=30d'
|
||||
- '--storage.tsdb.retention.size=5GB'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||
networks:
|
||||
- wordly-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Grafana - Dashboards & Visualization
|
||||
# ===========================================
|
||||
grafana:
|
||||
image: grafana/grafana:11.0.0
|
||||
container_name: wordly-grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=WordlyGrafana2026!
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_ROOT_URL=https://monitoring.wordly.art
|
||||
- GF_INSTALL_PLUGINS=grafana-clock-panel
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./docker/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
ports:
|
||||
- "3001:3000"
|
||||
networks:
|
||||
- wordly-network
|
||||
depends_on:
|
||||
prometheus:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 256M
|
||||
reservations:
|
||||
memory: 64M
|
||||
|
||||
# ===========================================
|
||||
# Node Exporter - System Metrics (CPU, RAM, Disk, Network)
|
||||
# ===========================================
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.8.0
|
||||
container_name: wordly-node-exporter
|
||||
restart: unless-stopped
|
||||
pid: host
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|etc|var/lib/docker)($$|/)'
|
||||
networks:
|
||||
- wordly-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64M
|
||||
|
||||
# ===========================================
|
||||
# cAdvisor - Docker Container Metrics
|
||||
# ===========================================
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.49.1
|
||||
container_name: wordly-cadvisor
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk:/dev/disk:ro
|
||||
privileged: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
networks:
|
||||
- wordly-network
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Volumes
|
||||
# ===========================================
|
||||
volumes:
|
||||
prometheus_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
|
||||
# ===========================================
|
||||
# Networks - Must match main docker-compose
|
||||
# ===========================================
|
||||
networks:
|
||||
wordly-network:
|
||||
external: true
|
||||
@@ -1,11 +1,15 @@
|
||||
# Document Translation API - Production Docker Compose
|
||||
# Usage: docker compose up -d
|
||||
# (or: docker-compose up -d)
|
||||
# ============================================
|
||||
# Wordly.art - Production Docker Compose
|
||||
# ============================================
|
||||
# Architecture: Nginx Proxy Manager (NPM) gere le SSL et reverse proxy
|
||||
# NPM pointe vers les services internes sur ce réseau Docker
|
||||
#
|
||||
# All services run in production mode (no code mounts, built images).
|
||||
# Secrets: set in .env at project root (see .env.example "Production / VPS" section).
|
||||
|
||||
version: '3.8'
|
||||
# Usage:
|
||||
# docker compose up -d
|
||||
#
|
||||
# Puis configurer NPM pour pointer vers:
|
||||
# wordly.art -> frontend:3000 (et backend:8000 pour /api/ et /translate)
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
@@ -13,18 +17,17 @@ services:
|
||||
# ===========================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: translate-postgres
|
||||
container_name: wordly-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-translate}
|
||||
# Production: set POSTGRES_PASSWORD in .env (required, no default — NFR10)
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-translate_db}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- translate-network
|
||||
- wordly-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||
interval: 10s
|
||||
@@ -39,41 +42,53 @@ services:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Backend API Service
|
||||
# Redis (Caching & Sessions)
|
||||
# ===========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: wordly-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- wordly-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
# ===========================================
|
||||
# Backend API (FastAPI)
|
||||
# ===========================================
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
target: production
|
||||
container_name: translate-backend
|
||||
container_name: wordly-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Database (POSTGRES_PASSWORD must be set in .env — no default)
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-translate_db}
|
||||
# Redis
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
# Translation Services
|
||||
- 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:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
# File Limits
|
||||
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||
# Rate Limiting
|
||||
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
|
||||
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
|
||||
# Admin Auth (CHANGE IN PRODUCTION!)
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
# Security (.env.example documents JWT_SECRET_KEY)
|
||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://yourdomain.com}
|
||||
# Stripe Payments
|
||||
- ADMIN_TOKEN_SECRET=${ADMIN_TOKEN_SECRET}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://wordly.art}
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||
@@ -84,7 +99,7 @@ services:
|
||||
- outputs_data:/app/outputs
|
||||
- logs_data:/app/logs
|
||||
networks:
|
||||
- translate-network
|
||||
- wordly-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -95,7 +110,7 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
start_period: 15s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -104,23 +119,23 @@ services:
|
||||
memory: 512M
|
||||
|
||||
# ===========================================
|
||||
# Frontend Web Service
|
||||
# Frontend (Next.js)
|
||||
# ===========================================
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
target: production
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
container_name: translate-frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: ""
|
||||
container_name: wordly-frontend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
|
||||
networks:
|
||||
- translate-network
|
||||
- wordly-network
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -131,44 +146,17 @@ services:
|
||||
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
|
||||
# Config syntax only; to verify proxy reachability run: curl -f https://your-domain/health (see DEPLOYMENT_GUIDE)
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ===========================================
|
||||
# Ollama (Optional - Local LLM)
|
||||
# ===========================================
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: translate-ollama
|
||||
container_name: wordly-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
networks:
|
||||
- translate-network
|
||||
- wordly-network
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
@@ -179,74 +167,13 @@ services:
|
||||
profiles:
|
||||
- with-ollama
|
||||
|
||||
# ===========================================
|
||||
# Redis (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: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
# ===========================================
|
||||
# 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:
|
||||
wordly-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
name: wordly-network
|
||||
|
||||
# ===========================================
|
||||
# Volumes
|
||||
@@ -260,13 +187,7 @@ volumes:
|
||||
driver: local
|
||||
logs_data:
|
||||
driver: local
|
||||
nginx_cache:
|
||||
redis_data:
|
||||
driver: local
|
||||
ollama_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
|
||||
@@ -26,11 +26,15 @@ FROM python:3.12-slim AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only
|
||||
# Install runtime dependencies + LibreOffice headless (required for DOCX→PDF)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libmagic1 \
|
||||
libpq5 \
|
||||
curl \
|
||||
fonts-noto \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-cjk-extra \
|
||||
libreoffice-writer-nogui \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
@@ -38,8 +42,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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 non-root user with a proper home directory (LibreOffice needs it)
|
||||
RUN groupadd -r translator && \
|
||||
useradd -r -g translator -m -d /home/translator translator && \
|
||||
mkdir -p /home/translator/.cache && \
|
||||
chown -R translator:translator /home/translator
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \
|
||||
|
||||
@@ -6,19 +6,28 @@ echo "🚀 Starting Document Translation API..."
|
||||
# Wait for database to be ready (if DATABASE_URL is set)
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
|
||||
# Extract host and port from DATABASE_URL
|
||||
# postgresql://user:pass@host:port/db
|
||||
DB_HOST=$(echo $DATABASE_URL | sed -e 's/.*@\([^:]*\):.*/\1/')
|
||||
DB_PORT=$(echo $DATABASE_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/')
|
||||
|
||||
|
||||
# Extract host and port from DATABASE_URL (handles postgresql+asyncpg:// and postgresql://)
|
||||
DB_HOST=$(python -c "
|
||||
import re
|
||||
m = re.search(r'@([^:/]+)', '$DATABASE_URL')
|
||||
print(m.group(1) if m else 'postgres')
|
||||
")
|
||||
DB_PORT=$(python -c "
|
||||
import re
|
||||
m = re.search(r'@[^:]+:(\d+)', '$DATABASE_URL')
|
||||
print(m.group(1) if m else '5432')
|
||||
")
|
||||
|
||||
echo " Connecting to ${DB_HOST}:${DB_PORT}..."
|
||||
|
||||
# Wait up to 30 seconds for database
|
||||
for i in {1..30}; do
|
||||
for i in $(seq 1 30); do
|
||||
if python -c "
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(('$DB_HOST', $DB_PORT))
|
||||
s.connect(('$DB_HOST', int('$DB_PORT')))
|
||||
s.close()
|
||||
exit(0)
|
||||
except:
|
||||
@@ -30,7 +39,7 @@ except:
|
||||
echo " Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
||||
# Run database migrations
|
||||
echo "📦 Running database migrations..."
|
||||
alembic upgrade head || echo "⚠️ Migration skipped (may already be up to date)"
|
||||
@@ -39,15 +48,23 @@ fi
|
||||
# Wait for Redis if configured
|
||||
if [ -n "$REDIS_URL" ]; then
|
||||
echo "⏳ Waiting for Redis..."
|
||||
REDIS_HOST=$(echo $REDIS_URL | sed -e 's/redis:\/\/\([^:]*\):.*/\1/')
|
||||
REDIS_PORT=$(echo $REDIS_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/')
|
||||
|
||||
for i in {1..10}; do
|
||||
REDIS_HOST=$(python -c "
|
||||
import re
|
||||
m = re.search(r'://([^:/]+)', '$REDIS_URL')
|
||||
print(m.group(1) if m else 'redis')
|
||||
")
|
||||
REDIS_PORT=$(python -c "
|
||||
import re
|
||||
m = re.search(r'://[^:]+:(\d+)', '$REDIS_URL')
|
||||
print(m.group(1) if m else '6379')
|
||||
")
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
if python -c "
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(('$REDIS_HOST', $REDIS_PORT))
|
||||
s.connect(('$REDIS_HOST', int('$REDIS_PORT')))
|
||||
s.close()
|
||||
exit(0)
|
||||
except:
|
||||
|
||||
168
docker/grafana/dashboards/wordly-infrastructure.json
Normal file
168
docker/grafana/dashboards/wordly-infrastructure.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"title": "CPU Usage (%)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "{{instance}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 20 },
|
||||
"unit": "percent",
|
||||
"max": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "RAM Usage",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes * 100",
|
||||
"legendFormat": "Used %"
|
||||
},
|
||||
{
|
||||
"expr": "node_memory_Buffers_bytes / node_memory_MemTotal_bytes * 100",
|
||||
"legendFormat": "Buffers %"
|
||||
},
|
||||
{
|
||||
"expr": "node_memory_Cached_bytes / node_memory_MemTotal_bytes * 100",
|
||||
"legendFormat": "Cache %"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2, "fillOpacity": 15 },
|
||||
"unit": "percent",
|
||||
"max": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Disk Space",
|
||||
"type": "gauge",
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 8 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"}) * 100",
|
||||
"legendFormat": "Used"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 90 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Network I/O",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 6, "w": 10, "x": 6, "y": 8 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(node_network_receive_bytes_total{device!=\"lo\"}[5m]) * 8",
|
||||
"legendFormat": "In {{device}}"
|
||||
},
|
||||
{
|
||||
"expr": "-rate(node_network_transmit_bytes_total{device!=\"lo\"}[5m]) * 8",
|
||||
"legendFormat": "Out {{device}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "lineWidth": 2 },
|
||||
"unit": "bps"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Container Memory",
|
||||
"type": "barchart",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "container_memory_usage_bytes{name=~\"wordly.*|translate.*\"}",
|
||||
"legendFormat": "{{name}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Container CPU %",
|
||||
"type": "barchart",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(container_cpu_usage_seconds_total{name=~\"wordly.*|translate.*\"}[5m]) * 100",
|
||||
"legendFormat": "{{name}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percent"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Service Status (Up/Down)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 24, "x": 0, "y": 22 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "up",
|
||||
"legendFormat": "{{job}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
},
|
||||
"mappings": [
|
||||
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["wordly", "infrastructure"],
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timezone": "Europe/Paris",
|
||||
"title": "Wordly - Infrastructure",
|
||||
"uid": "wordly-infra",
|
||||
"version": 1
|
||||
}
|
||||
206
docker/grafana/dashboards/wordly-overview.json
Normal file
206
docker/grafana/dashboards/wordly-overview.json
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"title": "Traductions (dernières 24h)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(increase(translation_total[24h]))",
|
||||
"legendFormat": "Total"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "blue", "value": null },
|
||||
{ "color": "green", "value": 10 },
|
||||
{ "color": "orange", "value": 50 }
|
||||
]
|
||||
},
|
||||
"unit": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Temps moyen (secondes)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "avg(rate(translation_duration_seconds_sum[5m]) / rate(translation_duration_seconds_count[5m]))",
|
||||
"legendFormat": "Avg"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 30 },
|
||||
{ "color": "red", "value": 60 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Taux d'erreur (%)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(http_requests_total{status=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100",
|
||||
"legendFormat": "Error %"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 5 }
|
||||
]
|
||||
},
|
||||
"unit": "percent",
|
||||
"max": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Utilisateurs actifs (1h)",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "count(increase(http_requests_total{path!=\"/health\",path!=\"/metrics\"}[1h]) > 0)",
|
||||
"legendFormat": "Active"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{ "color": "blue", "value": null },
|
||||
{ "color": "green", "value": 5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Requetes par minute (par endpoint)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (path) (rate(http_requests_total[5m]) * 60)",
|
||||
"legendFormat": "{{path}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 10
|
||||
},
|
||||
"unit": "req/min"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Temps de traduction (percentiles)",
|
||||
"type": "timeseries",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile(0.5, sum(rate(translation_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.95, sum(rate(translation_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile(0.99, sum(rate(translation_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 5
|
||||
},
|
||||
"unit": "s"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Traductions par provider",
|
||||
"type": "piechart",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (provider) (increase(translation_total[24h]))",
|
||||
"legendFormat": "{{provider}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Taille des fichiers uploades",
|
||||
"type": "histogram",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (le) (increase(file_size_bytes_bucket[24h]))",
|
||||
"legendFormat": "{{le}}"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Fichiers par type",
|
||||
"type": "piechart",
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum by (file_type) (increase(translation_total[24h]))",
|
||||
"legendFormat": "{{file_type}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"tags": ["wordly", "application"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "Europe/Paris",
|
||||
"title": "Wordly - Application",
|
||||
"uid": "wordly-app",
|
||||
"version": 1
|
||||
}
|
||||
13
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
13
docker/grafana/provisioning/dashboards/dashboards.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'Wordly Dashboards'
|
||||
orgId: 1
|
||||
folder: 'Wordly'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
editable: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
foldersFromFilesStructure: true
|
||||
12
docker/grafana/provisioning/datasources/datasources.yml
Normal file
12
docker/grafana/provisioning/datasources/datasources.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://wordly-prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: '15s'
|
||||
httpMethod: POST
|
||||
@@ -1,18 +1,11 @@
|
||||
# Document Translation API - Main Server Block
|
||||
# HTTP to HTTPS redirect and main application routing
|
||||
# Wordly.art - Production Nginx Config
|
||||
# HTTP to HTTPS redirect + main application routing
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
# HTTP server - redirect to HTTPS + Let's Encrypt
|
||||
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 "";
|
||||
}
|
||||
server_name wordly.art www.wordly.art;
|
||||
|
||||
# ACME challenge for Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
@@ -21,7 +14,7 @@ server {
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
return 301 https://wordly.art$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,19 +22,16 @@ server {
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name _;
|
||||
server_name wordly.art;
|
||||
|
||||
# SSL certificates (replace with your paths)
|
||||
# SSL certificates
|
||||
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 hardening
|
||||
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;
|
||||
@@ -58,9 +48,16 @@ server {
|
||||
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;
|
||||
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' https://wordly.art ws: wss:;" always;
|
||||
|
||||
# API routes - proxy to backend (preserve full path so FastAPI receives /api/v1/...)
|
||||
# File upload size
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
|
||||
# API routes -> Backend
|
||||
location /api/ {
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
@@ -72,8 +69,7 @@ server {
|
||||
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 (Origin restricted to same-origin/localhost via map in nginx.conf)
|
||||
|
||||
add_header Access-Control-Allow-Origin $cors_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, X-API-Key" always;
|
||||
@@ -84,7 +80,7 @@ server {
|
||||
}
|
||||
}
|
||||
|
||||
# File upload endpoint - special handling
|
||||
# Translation endpoint - extended timeouts
|
||||
location /translate {
|
||||
limit_req zone=upload_limit burst=5 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
@@ -95,21 +91,51 @@ server {
|
||||
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
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Admin UI -> Frontend (Next.js page)
|
||||
# Prometheus metrics (internal only - restrict access)
|
||||
location /metrics {
|
||||
# Allow only from Docker network and localhost
|
||||
allow 172.28.0.0/16;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://backend/metrics;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# API docs
|
||||
location /docs {
|
||||
proxy_pass http://backend/docs;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend/redoc;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend/openapi.json;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Admin -> Frontend
|
||||
location /admin {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
@@ -119,7 +145,13 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend - Next.js application
|
||||
# Frontend static assets with aggressive caching
|
||||
location /_next/static/ {
|
||||
proxy_pass http://frontend;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Frontend -> Next.js
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
@@ -131,16 +163,21 @@ server {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect www to non-www
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name www.wordly.art;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
|
||||
return 301 https://wordly.art$request_uri;
|
||||
}
|
||||
|
||||
101
docker/prometheus/alerts.yml
Normal file
101
docker/prometheus/alerts.yml
Normal file
@@ -0,0 +1,101 @@
|
||||
# Wordly.art - Prometheus Alert Rules
|
||||
|
||||
groups:
|
||||
# Application alerts
|
||||
- name: wordly_app
|
||||
rules:
|
||||
- alert: BackendDown
|
||||
expr: up{job="wordly-backend"} == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Wordly backend is down"
|
||||
description: "Backend has been down for more than 2 minutes."
|
||||
|
||||
- alert: HighErrorRate
|
||||
expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.1
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "More than 10% of requests are returning 5xx errors."
|
||||
|
||||
- alert: SlowTranslations
|
||||
expr: histogram_quantile(0.95, rate(translation_duration_seconds_bucket[5m])) > 120
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Translations are slow"
|
||||
description: "95th percentile translation time is over 120 seconds."
|
||||
|
||||
- alert: HighTranslationQueue
|
||||
expr: translation_queue_size > 20
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Translation queue is backing up"
|
||||
description: "More than 20 translations queued."
|
||||
|
||||
# System alerts
|
||||
- name: wordly_system
|
||||
rules:
|
||||
- alert: HighMemoryUsage
|
||||
expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes > 0.9
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High memory usage"
|
||||
description: "Server memory usage is above 90%."
|
||||
|
||||
- alert: DiskSpaceLow
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.15
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Low disk space"
|
||||
description: "Less than 15% disk space remaining on /."
|
||||
|
||||
- alert: DiskSpaceCritical
|
||||
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.05
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Critical disk space"
|
||||
description: "Less than 5% disk space remaining on /."
|
||||
|
||||
- alert: HighCPUUsage
|
||||
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "High CPU usage"
|
||||
description: "CPU usage is above 85% for 10 minutes."
|
||||
|
||||
# Docker alerts
|
||||
- name: wordly_docker
|
||||
rules:
|
||||
- alert: ContainerRestarted
|
||||
expr: increase(container_restart_count[1h]) > 2
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Container restarting"
|
||||
description: "Container {{ $labels.name }} has restarted more than 2 times in the last hour."
|
||||
|
||||
- alert: ContainerOOM
|
||||
expr: increase(container_oom_events_total[1h]) > 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "Container OOM killed"
|
||||
description: "Container {{ $labels.name }} was OOM killed."
|
||||
@@ -1,37 +1,34 @@
|
||||
# Prometheus Configuration for Document Translation API
|
||||
# Wordly.art - Prometheus Configuration
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: 'translate-api'
|
||||
monitor: 'wordly-homelab'
|
||||
environment: 'production'
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
|
||||
rule_files: []
|
||||
rule_files:
|
||||
- 'alerts.yml'
|
||||
|
||||
scrape_configs:
|
||||
# Backend API metrics
|
||||
- job_name: 'translate-backend'
|
||||
# Backend FastAPI
|
||||
- job_name: 'wordly-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
|
||||
# Systeme (CPU, RAM, Disk, Reseau)
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
# Docker metrics
|
||||
# Containers Docker
|
||||
- job_name: 'docker'
|
||||
static_configs:
|
||||
- targets: ['cadvisor:8080']
|
||||
|
||||
# Prometheus lui-meme
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
37
frontend/package-lock.json
generated
37
frontend/package-lock.json
generated
@@ -172,6 +172,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -534,6 +535,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -574,6 +576,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -4591,7 +4594,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -4612,7 +4614,6 @@
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -4688,8 +4689,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -4781,6 +4781,7 @@
|
||||
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4791,6 +4792,7 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -4801,6 +4803,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -4851,6 +4854,7 @@
|
||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
@@ -5495,6 +5499,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5638,7 +5643,6 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -6072,6 +6076,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6630,7 +6635,6 @@
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -6668,8 +6672,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -7008,6 +7011,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7193,6 +7197,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -7490,6 +7495,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -8142,6 +8148,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -8863,6 +8870,7 @@
|
||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.31",
|
||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||
@@ -9345,7 +9353,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -10191,7 +10198,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -10207,7 +10213,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -10220,8 +10225,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@@ -10356,6 +10360,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10365,6 +10370,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11501,6 +11507,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11764,6 +11771,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12011,6 +12019,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12104,6 +12113,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12468,6 +12478,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Sidebar } from "@/components/sidebar"
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Sidebar />
|
||||
<main className="ml-64 min-h-screen p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, ArrowRight, AlertCircle, CheckCircle, Zap } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings(localSettings);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
||||
applyPreset(preset);
|
||||
// Need to get updated values from store after applying preset
|
||||
setTimeout(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||
glossary: useTranslationStore.getState().settings.glossary,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearContext();
|
||||
setLocalSettings({
|
||||
systemPrompt: "",
|
||||
glossary: "",
|
||||
});
|
||||
};
|
||||
|
||||
// Check which LLM providers are configured
|
||||
const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel;
|
||||
const isOpenAIConfigured = !!settings.openaiApiKey;
|
||||
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Brain className="h-3 w-3 mr-1" />
|
||||
Context & Glossary
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">
|
||||
Context & Glossary
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure translation context and glossary for LLM-based providers
|
||||
</p>
|
||||
|
||||
{/* LLM Provider Status */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOllamaConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOllamaConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🤖 Ollama</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOpenAIConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpenAIConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🧠 OpenAI</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isWebLLMAvailable
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWebLLMAvailable && <CheckCircle className="h-4 w-4" />}
|
||||
<span>💻 WebLLM</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card variant="gradient" className="mb-8 animate-fade-in-up">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Context & Glossary Settings
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed">
|
||||
These settings apply to all LLM providers: <strong>Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
||||
Use them to improve translation quality with domain-specific instructions and terminology.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Left Column - System Prompt */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">System Prompt</CardTitle>
|
||||
<CardDescription>
|
||||
Instructions for LLM to follow during translation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="system-prompt"
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
||||
className="bg-surface border-border-subtle text-foreground placeholder:text-text-tertiary min-h-[200px] resize-y focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<p className="text-sm text-primary flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Tip:</strong> Include domain context, tone preferences, or specific terminology rules for better translation accuracy.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Zap className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Quick Presets</CardTitle>
|
||||
<CardDescription>
|
||||
Load pre-configured prompts & glossaries for common domains
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("hvac")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🔧</div>
|
||||
<div className="font-medium">HVAC / Engineering</div>
|
||||
<div className="text-xs text-text-tertiary">Technical terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("it")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">💻</div>
|
||||
<div className="font-medium">IT / Software</div>
|
||||
<div className="text-xs text-text-tertiary">Development terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("legal")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">⚖️</div>
|
||||
<div className="font-medium">Legal / Contracts</div>
|
||||
<div className="text-xs text-text-tertiary">Legal terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("medical")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🏥</div>
|
||||
<div className="font-medium">Medical / Healthcare</div>
|
||||
<div className="text-xs text-text-tertiary">Medical terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClear}
|
||||
className="w-full text-destructive hover:text-destructive/80 hover:bg-destructive/10 group"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear All
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Glossary */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<BookOpen className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Technical Glossary</CardTitle>
|
||||
<CardDescription>
|
||||
Define specific term translations. Format: source=target (one per line)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="glossary"
|
||||
value={localSettings.glossary}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||
}
|
||||
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||
className="bg-surface border-border-subtle text-foreground placeholder:text-text-tertiary min-h-[280px] resize-y font-mono text-sm focus:border-success focus:ring-success/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<p className="text-sm text-success flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Pro Tip:</strong> The glossary is included in system prompt to guide translations and ensure consistent terminology.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Examples */}
|
||||
<Card variant="glass" className="animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<AlertCircle className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-foreground">Usage Examples</CardTitle>
|
||||
<CardDescription>
|
||||
See how context and glossary improve translations
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-surface/50 border border-border-subtle">
|
||||
<h4 className="font-medium text-foreground mb-2">Before (Generic Translation)</h4>
|
||||
<p className="text-sm text-text-tertiary italic">
|
||||
"The pressure in the duct should be maintained."
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<h4 className="font-medium text-foreground mb-2">After (With Context & Glossary)</h4>
|
||||
<p className="text-sm text-success italic">
|
||||
"La pression statique dans la gaine doit être maintenue."
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
<strong>Key improvements:</strong> Technical terms are correctly translated, context is preserved, and industry-standard terminology is used.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end animate-fade-in-up animation-delay-500">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-foreground group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { languages } from "@/lib/api";
|
||||
import { Save, Loader2, Settings, Globe, Trash2, ArrowRight, Shield, Zap, Database } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultLanguage(settings.defaultTargetLanguage);
|
||||
}, [settings.defaultTargetLanguage]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('translation-settings');
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
// Clear any cached files/blobs
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Reload to reset state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Settings
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
General Settings
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure general application settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up">
|
||||
<Link href="/settings/services" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-primary/20 group-hover:bg-primary/30 transition-colors duration-300">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translation Services
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Configure providers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-primary">
|
||||
<span className="text-sm font-medium">Manage providers</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-100">
|
||||
<Link href="/settings/context" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-accent/20 group-hover:bg-accent/30 transition-colors duration-300">
|
||||
<Globe className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-accent transition-colors duration-300">
|
||||
Context & Glossary
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Domain-specific settings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-accent">
|
||||
<span className="text-sm font-medium">Configure context</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-success/20 group-hover:bg-success/30 transition-colors duration-300">
|
||||
<Shield className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-success transition-colors duration-300">
|
||||
Privacy & Security
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Data protection</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-success">
|
||||
<span className="text-sm font-medium">Coming soon</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Application Settings */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Application Settings</CardTitle>
|
||||
<CardDescription>
|
||||
General configuration options
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="default-language" className="text-text-secondary font-medium">
|
||||
Default Target Language
|
||||
</Label>
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||
<SelectValue placeholder="Select default language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
This language will be pre-selected when translating documents
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Globe className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Supported Formats</CardTitle>
|
||||
<CardDescription>
|
||||
Document types that can be translated
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📊</div>
|
||||
<h3 className="font-semibold text-white mb-2">Excel</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.xlsx, .xls</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Formulas
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Styles
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📝</div>
|
||||
<h3 className="font-semibold text-white mb-2">Word</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.docx, .doc</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Headers
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Tables
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📽️</div>
|
||||
<h3 className="font-semibold text-white mb-2">PowerPoint</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.pptx, .ppt</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Slides
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Notes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<Database className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">API Information</CardTitle>
|
||||
<CardDescription>
|
||||
Backend server connection details
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">API Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">http://localhost:8000</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Health Check</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/health</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Translate Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/translate</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/20">
|
||||
<Shield className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">System Status</CardTitle>
|
||||
<CardDescription>
|
||||
Application health and performance
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Connection Status</span>
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10">
|
||||
<div className="w-2 h-2 bg-success rounded-full mr-2 animate-pulse"></div>
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Last Sync</span>
|
||||
<span className="text-sm text-text-secondary">Just now</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Version</span>
|
||||
<span className="text-sm text-text-secondary">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center animate-fade-in-up animation-delay-700">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Need help with settings? Check our documentation.
|
||||
</p>
|
||||
<Button variant="glass" size="sm" className="group">
|
||||
<Settings className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-90" />
|
||||
View Documentation
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10 hover:border-destructive group"
|
||||
>
|
||||
{isClearing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Clearing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear Cache
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,694 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
Crown, Zap, Sparkles, Building2, Rocket, BadgeCheck,
|
||||
ArrowRight, AlertTriangle, CheckCircle2, XCircle,
|
||||
BarChart3, FileText, Layers, Brain, CreditCard,
|
||||
RefreshCw, ExternalLink, ChevronRight, Info,
|
||||
TrendingUp, Calendar, Gauge
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
───────────────────────────────────────────── */
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
subscription_status: string;
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
api_calls_this_month: number;
|
||||
extra_credits: number;
|
||||
subscription_ends_at?: string;
|
||||
cancel_at_period_end?: boolean;
|
||||
}
|
||||
|
||||
interface UsageLimits {
|
||||
plan: string;
|
||||
docs_used: number;
|
||||
docs_limit: number;
|
||||
pages_used: number;
|
||||
pages_limit: number;
|
||||
api_calls_used: number;
|
||||
api_calls_limit: number;
|
||||
can_translate: boolean;
|
||||
upgrade_required: boolean;
|
||||
extra_credits: number;
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
max_file_size_mb: number;
|
||||
features: string[];
|
||||
ai_translation: boolean;
|
||||
ai_tier?: string;
|
||||
api_access: boolean;
|
||||
priority_processing: boolean;
|
||||
team_seats?: number;
|
||||
popular?: boolean;
|
||||
badge?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Helpers
|
||||
───────────────────────────────────────────── */
|
||||
const PLAN_ICONS: Record<string, any> = {
|
||||
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
|
||||
};
|
||||
const PLAN_COLORS: Record<string, string> = {
|
||||
free: "from-slate-600 to-slate-700",
|
||||
starter: "from-blue-600 to-blue-700",
|
||||
pro: "from-violet-600 to-violet-700",
|
||||
business: "from-emerald-600 to-emerald-700",
|
||||
enterprise: "from-amber-600 to-amber-700",
|
||||
};
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: "Gratuit", starter: "Starter", pro: "Pro",
|
||||
business: "Business", enterprise: "Entreprise",
|
||||
};
|
||||
|
||||
function pct(used: number, limit: number) {
|
||||
if (limit === -1) return 0;
|
||||
return Math.min(100, Math.round((used / limit) * 100));
|
||||
}
|
||||
|
||||
function fmtLimit(val: number) {
|
||||
return val === -1 ? "Illimité" : String(val);
|
||||
}
|
||||
|
||||
function UsageBar({
|
||||
label, used, limit, icon,
|
||||
}: { label: string; used: number; limit: number; icon: React.ReactNode }) {
|
||||
const p = pct(used, limit);
|
||||
const isUnlimited = limit === -1;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<span className={cn(
|
||||
"font-mono text-xs",
|
||||
isUnlimited ? "text-emerald-500" :
|
||||
p >= 90 ? "text-destructive" : p >= 70 ? "text-warning" : "text-muted-foreground"
|
||||
)}>
|
||||
{isUnlimited ? "∞" : `${used} / ${limit}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-700",
|
||||
p >= 90 ? "bg-destructive" : p >= 70 ? "bg-warning" : "bg-accent"
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Main component
|
||||
───────────────────────────────────────────── */
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const targetPlan = searchParams.get("plan");
|
||||
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [usage, setUsage] = useState<UsageLimits | null>(null);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [loadingPortal, setLoadingPortal] = useState(false);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isProcessingCheckout, setIsProcessingCheckout] = useState(false);
|
||||
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!token) { router.push("/auth/login?redirect=/settings/subscription"); return; }
|
||||
try {
|
||||
const [meRes, usageRes, plansRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/auth/me`, { headers: authHeaders }),
|
||||
fetch(`${API_BASE}/api/v1/auth/usage`, { headers: authHeaders }),
|
||||
fetch(`${API_BASE}/api/v1/auth/plans`),
|
||||
]);
|
||||
if (meRes.ok) {
|
||||
const j = await meRes.json();
|
||||
setUser(j.data ?? j);
|
||||
}
|
||||
if (usageRes.ok) {
|
||||
const j = await usageRes.json();
|
||||
setUsage(j.data ?? j);
|
||||
}
|
||||
if (plansRes.ok) {
|
||||
const j = await plansRes.json();
|
||||
const d = j.data ?? j;
|
||||
if (Array.isArray(d.plans)) setPlans(d.plans);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setLoadingPortal(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/billing-portal`, { headers: authHeaders });
|
||||
const j = await res.json();
|
||||
const url = j.data?.url ?? j.url;
|
||||
if (url) window.open(url, "_blank");
|
||||
else setStatusMsg({ type: "err", text: "Portail de facturation non disponible pour le moment." });
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Impossible d'accéder au portail de facturation." });
|
||||
} finally {
|
||||
setLoadingPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!cancelConfirm) { setCancelConfirm(true); return; }
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatusMsg({ type: "ok", text: "Abonnement annulé. Vous conservez l'accès jusqu'à la fin de la période en cours." });
|
||||
setCancelConfirm(false);
|
||||
fetchData();
|
||||
} else {
|
||||
setStatusMsg({ type: "err", text: "Erreur lors de l'annulation. Réessayez ou contactez le support." });
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur réseau." });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
if (planId === "enterprise") {
|
||||
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: `Création de votre session de paiement pour le forfait ${PLAN_LABELS[planId] ?? planId}…`,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/create-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan: planId, billing_period: isYearly ? "yearly" : "monthly" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = data.message ?? data.error ?? "Erreur lors de la création de la session de paiement.";
|
||||
setStatusMsg({ type: "err", text: msg });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = data.data?.url ?? data.url;
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
await handleBillingPortal();
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur réseau lors de la création de la session de paiement." });
|
||||
} finally {
|
||||
setIsProcessingCheckout(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle targetPlan from URL query param automatically (MUST be after handleSubscribe declaration)
|
||||
useEffect(() => {
|
||||
if (targetPlan && plans.length > 0 && !loading && !isProcessingCheckout) {
|
||||
const p = plans.find(plan => plan.id === targetPlan);
|
||||
if (p && user?.plan !== targetPlan) {
|
||||
setIsProcessingCheckout(true);
|
||||
// Clean URL to prevent infinite re-triggering
|
||||
router.replace("/settings/subscription", { scroll: false });
|
||||
handleSubscribe(targetPlan);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetPlan, plans, loading, user]);
|
||||
|
||||
const handleCreditsCheckout = async (packageIndex: number) => {
|
||||
setStatusMsg({
|
||||
type: "ok",
|
||||
text: "Préparation de l'achat de crédits...",
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/create-credits-checkout`, {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ package_index: packageIndex }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const url = data.data?.url ?? data.url;
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
} else {
|
||||
setTimeout(() => handleBillingPortal(), 1000);
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: "err", text: "Erreur lors de la création de la session de paiement." });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || isProcessingCheckout) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<RefreshCw className="w-8 h-8 text-violet-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentPlanId = user?.plan ?? "free";
|
||||
const currentPlanLabel = PLAN_LABELS[currentPlanId] ?? currentPlanId;
|
||||
const Icon = PLAN_ICONS[currentPlanId] ?? Sparkles;
|
||||
const gradient = PLAN_COLORS[currentPlanId] ?? PLAN_COLORS.free;
|
||||
const currentPlanData = plans.find((p) => p.id === currentPlanId);
|
||||
|
||||
const otherPlans = plans.filter((p) => p.id !== currentPlanId);
|
||||
const upgradePlans = otherPlans.filter((p) => {
|
||||
const order = ["free", "starter", "pro", "business", "enterprise"];
|
||||
return order.indexOf(p.id) > order.indexOf(currentPlanId);
|
||||
});
|
||||
const downgradePlans = otherPlans.filter((p) => {
|
||||
const order = ["free", "starter", "pro", "business", "enterprise"];
|
||||
return order.indexOf(p.id) < order.indexOf(currentPlanId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Mon abonnement</h1>
|
||||
<p className="text-muted-foreground mt-1">Gérez votre forfait, votre usage et votre facturation.</p>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{statusMsg && (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 p-4 rounded-xl border",
|
||||
statusMsg.type === "ok"
|
||||
? "bg-success/10 border-success/30 text-success"
|
||||
: "bg-destructive/10 border-destructive/30 text-destructive"
|
||||
)}>
|
||||
{statusMsg.type === "ok"
|
||||
? <CheckCircle2 className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
: <XCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />}
|
||||
<span className="text-sm">{statusMsg.text}</span>
|
||||
<button className="ml-auto text-muted-foreground hover:text-foreground" onClick={() => setStatusMsg(null)}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Current plan card ── */}
|
||||
<div className={cn("rounded-2xl p-1 bg-gradient-to-br", gradient)}>
|
||||
<div className="bg-card/95 rounded-xl p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={cn("p-3 rounded-xl bg-gradient-to-br", gradient)}>
|
||||
<Icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-bold text-foreground">Forfait {currentPlanLabel}</h2>
|
||||
{user?.subscription_status && (
|
||||
<Badge className={cn(
|
||||
"text-xs",
|
||||
user.subscription_status === "active" ? "bg-success/10 text-success border-success/30" :
|
||||
user.subscription_status === "trialing" ? "bg-primary/10 text-primary border-primary/30" :
|
||||
"bg-warning/10 text-warning border-warning/30"
|
||||
)}>
|
||||
{user.subscription_status === "active" ? "Actif" :
|
||||
user.subscription_status === "trialing" ? "Essai" :
|
||||
user.subscription_status === "canceled" ? "Annulé" :
|
||||
user.subscription_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{user?.subscription_ends_at && (
|
||||
<p className="text-muted-foreground text-sm mt-0.5">
|
||||
<Calendar className="w-3.5 h-3.5 inline mr-1" />
|
||||
{user.cancel_at_period_end ? "Expire le " : "Renouvellement le "}
|
||||
{new Date(user.subscription_ends_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentPlanId !== "free" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
>
|
||||
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin mr-1" /> : <CreditCard className="w-4 h-4 mr-1" />}
|
||||
Portail de facturation
|
||||
<ExternalLink className="w-3.5 h-3.5 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
{upgradePlans.length > 0 && (
|
||||
<Button
|
||||
className="bg-accent hover:bg-accent/90 text-accent-foreground"
|
||||
onClick={() => router.push("/pricing")}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Usage this month ── */}
|
||||
{usage && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-accent" />
|
||||
Utilisation ce mois
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal">Remise à zéro chaque 1er du mois</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<UsageBar
|
||||
label="Documents traduits"
|
||||
used={usage.docs_used}
|
||||
limit={usage.docs_limit}
|
||||
icon={<FileText className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
<UsageBar
|
||||
label="Pages traduites"
|
||||
used={usage.pages_used}
|
||||
limit={usage.pages_limit}
|
||||
icon={<Layers className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
{usage.api_calls_limit !== 0 && (
|
||||
<UsageBar
|
||||
label="Appels API"
|
||||
used={usage.api_calls_used}
|
||||
limit={usage.api_calls_limit}
|
||||
icon={<Gauge className="w-4 h-4 text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_credits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-warning/10 border border-warning/20 text-sm">
|
||||
<Info className="w-4 h-4 text-warning flex-shrink-0" />
|
||||
<span className="text-warning">
|
||||
{usage.extra_credits} crédit{usage.extra_credits > 1 ? "s" : ""} supplémentaire{usage.extra_credits > 1 ? "s" : ""} disponible{usage.extra_credits > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{usage.upgrade_required && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-xl bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-destructive">
|
||||
Quota atteint. Achetez des crédits ou upgradez votre forfait pour continuer.
|
||||
</span>
|
||||
<Button size="sm" className="ml-auto bg-destructive hover:bg-destructive/90 text-destructive-foreground flex-shrink-0" onClick={() => router.push("/pricing")}>
|
||||
Upgrader
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Plan features recap ── */}
|
||||
{currentPlanData && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BadgeCheck className="w-5 h-5 text-success" />
|
||||
Inclus dans votre forfait
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{currentPlanData.features.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Upgrade options ── */}
|
||||
{upgradePlans.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-accent" />
|
||||
Passer à un forfait supérieur
|
||||
</h3>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="inline-flex items-center gap-2 bg-muted/60 border border-border/50 rounded-full p-1 mb-4">
|
||||
<button
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all", !isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
|
||||
>Mensuel</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", isYearly ? "bg-foreground text-background" : "text-muted-foreground")}
|
||||
>
|
||||
Annuel
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">−20 %</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{upgradePlans.map((plan) => {
|
||||
const PIcon = PLAN_ICONS[plan.id] ?? Zap;
|
||||
const grad = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
|
||||
const price = plan.price_monthly === -1
|
||||
? null
|
||||
: isYearly
|
||||
? (plan.price_yearly / 12).toFixed(2)
|
||||
: plan.price_monthly.toFixed(2);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"relative rounded-2xl border bg-card overflow-hidden",
|
||||
plan.popular ? "border-accent/50" : "border-border/40"
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Badge className="bg-accent text-accent-foreground text-xs">{plan.badge}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("p-4 bg-gradient-to-br", grad)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<PIcon className="w-5 h-5 text-white" />
|
||||
<span className="font-bold text-white">{plan.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-end gap-1">
|
||||
{price === null ? (
|
||||
<span className="text-2xl font-bold text-white">Sur devis</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-white">{price} €</span>
|
||||
<span className="text-white/70 text-sm pb-0.5">/mois</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{plan.features.slice(0, 4).map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-success flex-shrink-0 mt-0.5" />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
{plan.features.length > 4 && (
|
||||
<p className="text-xs text-muted-foreground">+{plan.features.length - 4} autres avantages…</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
className={cn(
|
||||
"mt-3 w-full py-2 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all",
|
||||
`bg-gradient-to-r ${grad} hover:opacity-90`
|
||||
)}
|
||||
>
|
||||
Passer au forfait {plan.name} <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Buy credits ── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5 text-warning" />
|
||||
Crédits supplémentaires
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">1 crédit = 1 page traduite. Utilisables sans expiration.</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ credits: 50, price: 5 },
|
||||
{ credits: 150, price: 12, popular: true },
|
||||
{ credits: 500, price: 35 },
|
||||
{ credits: 1000, price: 60 },
|
||||
].map((pkg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"relative p-4 rounded-xl border text-center",
|
||||
pkg.popular
|
||||
? "border-warning/50 bg-warning/10"
|
||||
: "border-border/40 bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-warning text-warning-foreground text-xs rounded-full font-bold whitespace-nowrap">
|
||||
Meilleure valeur
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold text-foreground">{pkg.credits}</div>
|
||||
<div className="text-muted-foreground text-xs mb-2">crédits</div>
|
||||
<div className="text-lg font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
|
||||
<button
|
||||
onClick={() => handleCreditsCheckout(i)}
|
||||
className="w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all"
|
||||
>
|
||||
Acheter
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Downgrade / Cancel ── */}
|
||||
{currentPlanId !== "free" && (
|
||||
<Card className="border-destructive/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Zone de danger
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{downgradePlans.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">Rétrograder vers un forfait inférieur :</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{downgradePlans.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleSubscribe(p.id)}
|
||||
className="px-4 py-2 rounded-lg bg-muted hover:bg-secondary text-muted-foreground text-sm border border-border/50 transition-all"
|
||||
>
|
||||
Passer à {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border/30 pt-4">
|
||||
{!cancelConfirm ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Annuler mon abonnement</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setCancelConfirm(true)}
|
||||
>
|
||||
Annuler l'abonnement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-destructive/10 border border-destructive/30 space-y-3">
|
||||
<p className="text-sm text-destructive font-medium">
|
||||
⚠️ Confirmer l'annulation ?
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Votre abonnement sera annulé et vous reviendrez au forfait Gratuit à la fin de la période en cours.
|
||||
Vos documents traduits resteront accessibles pendant 30 jours.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Oui, annuler mon abonnement
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelConfirm(false)}
|
||||
>
|
||||
Non, conserver
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Link to full pricing */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => router.push("/pricing")}
|
||||
className="text-accent hover:text-accent/80 text-sm flex items-center gap-1 mx-auto"
|
||||
>
|
||||
Voir tous les forfaits en détail <ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,13 @@ import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -38,9 +40,9 @@ export function AdminHeader() {
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 lg:flex">
|
||||
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
|
||||
<h1 className="text-xs font-semibold text-foreground">{t('admin.dashboard.title')}</h1>
|
||||
<Separator orientation="vertical" className="h-3" />
|
||||
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
|
||||
<span className="text-xs text-muted-foreground">{t('admin.dashboard.subtitle')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -48,7 +50,7 @@ export function AdminHeader() {
|
||||
variant="outline"
|
||||
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<Shield className="mr-1 size-2.5" />
|
||||
<Shield className="me-1 size-2.5" />
|
||||
Superadmin
|
||||
</Badge>
|
||||
<Avatar className="size-6">
|
||||
@@ -66,7 +68,7 @@ export function AdminHeader() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
@@ -77,7 +79,7 @@ export function AdminHeader() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAdminLogin } from "./login/useAdminLogin";
|
||||
import { adminNavItems } from "./constants";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { logout } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
@@ -38,7 +40,7 @@ export function AdminSidebar() {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
|
||||
@@ -48,7 +50,7 @@ export function AdminSidebar() {
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-3.5 shrink-0" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { AdminDashboardData } from "./types";
|
||||
|
||||
interface SystemHealthCardsProps {
|
||||
@@ -21,6 +22,7 @@ export function SystemHealthCards({
|
||||
onPurge,
|
||||
purgeResult,
|
||||
}: SystemHealthCardsProps) {
|
||||
const { t } = useI18n();
|
||||
const diskUsed = data?.system?.disk?.used_percent ?? 0;
|
||||
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
|
||||
const systemStatus = data?.status ?? "unhealthy";
|
||||
@@ -82,14 +84,14 @@ export function SystemHealthCards({
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{systemStatus === "healthy"
|
||||
? "All Systems Operational"
|
||||
: "System Issues Detected"}
|
||||
? t('admin.system.allOperational')
|
||||
: t('admin.system.issuesDetected')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{data?.timestamp
|
||||
{data?.timestamp
|
||||
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
|
||||
: "Waiting for data..."}
|
||||
: t('admin.system.waitingData')}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -151,10 +153,10 @@ export function SystemHealthCards({
|
||||
<Trash2 className="size-3" />
|
||||
)}
|
||||
{isPurging
|
||||
? "Purging..."
|
||||
? t('admin.system.purging')
|
||||
: trackedFilesCount === 0
|
||||
? "Clean"
|
||||
: "Purge"}
|
||||
? t('admin.system.clean')
|
||||
: t('admin.system.purge')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Rang</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="text-right">Traductions</TableHead>
|
||||
<TableHead className="text-end">Traductions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -120,7 +120,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
|
||||
<TableRow key={user.user_id}>
|
||||
<TableCell>{getRankBadge(index + 1)}</TableCell>
|
||||
<TableCell className="font-medium">{user.email}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-end">
|
||||
<Badge variant="secondary">{user.translation_count}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AdminNavItem {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const adminNavItems: AdminNavItem[] = [
|
||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||
{ label: 'Pricing & Stripe', href: '/admin/pricing', icon: CreditCard },
|
||||
{ label: 'Providers', href: '/admin/settings', icon: Settings },
|
||||
{ label: 'System', href: '/admin/system', icon: Settings },
|
||||
{ label: 'Logs', href: '/admin/logs', icon: FileText },
|
||||
{ labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard },
|
||||
{ labelKey: 'admin.nav.users', href: '/admin/users', icon: Users },
|
||||
{ labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard },
|
||||
{ labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings },
|
||||
{ labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings },
|
||||
{ labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import { AdminHeader } from "./AdminHeader";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -15,9 +16,9 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { settings, setAdminToken } = useTranslationStore();
|
||||
const { t } = useI18n();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
/** Sans ça, au premier rendu après un chargement complet le token n’est pas encore lu depuis localStorage → fausse absence de token → redirection /admin/login (bug sur F5 ou URL directe). */
|
||||
const [persistHydrated, setPersistHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,7 +76,7 @@ export default function AdminLayout({
|
||||
if (isChecking && pathname !== "/admin/login") {
|
||||
return (
|
||||
<div className="min-h-screen bg-card flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Vérification de l'authentification...</div>
|
||||
<div className="text-muted-foreground text-sm">{t('admin.layout.checking')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAdminLogin } from "./useAdminLogin";
|
||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
function AdminLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, isLoading, error } = useAdminLogin();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -25,8 +27,8 @@ function AdminLoginContent() {
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||
<h1 className="text-2xl font-bold text-white">{t('admin.login.title')}</h1>
|
||||
<p className="text-gray-400 mt-2">{t('admin.login.required')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||
@@ -39,7 +41,7 @@ function AdminLoginContent() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Mot de passe administrateur</label>
|
||||
<label className="block text-gray-400 text-sm mb-2">{t('admin.login.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
@@ -47,7 +49,7 @@ function AdminLoginContent() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
className="w-full ps-12 pe-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -69,12 +71,12 @@ function AdminLoginContent() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion...
|
||||
{t('admin.login.connecting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Accéder au panneau admin
|
||||
{t('admin.login.access')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -82,7 +84,7 @@ function AdminLoginContent() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Accès réservé aux administrateurs
|
||||
{t('admin.login.restricted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,10 +92,11 @@ function AdminLoginContent() {
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Chargement...</div>
|
||||
<div className="text-white text-xl">{t('common.loading')}</div>
|
||||
</div>
|
||||
}>
|
||||
<AdminLoginContent />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SystemHealthCards } from "./SystemHealthCards";
|
||||
import { ProviderStatus } from "./ProviderStatus";
|
||||
import { useAdminDashboard } from "./useAdminDashboard";
|
||||
import { useCleanup } from "./useCleanup";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
export default function AdminPage() {
|
||||
const { data, isLoading, error, refetch } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handlePurge = async () => {
|
||||
await triggerCleanup();
|
||||
@@ -32,10 +34,10 @@ export default function AdminPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Dashboard Admin
|
||||
{t('admin.dashboard.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Panneau de contrôle administrateur
|
||||
{t('admin.dashboard.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,11 +56,11 @@ export default function AdminPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3.5" />
|
||||
)}
|
||||
Refresh
|
||||
{t('admin.dashboard.refresh')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Refresh dashboard data</p>
|
||||
<p>{t('admin.dashboard.refreshTooltip')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -83,23 +85,23 @@ export default function AdminPage() {
|
||||
{data?.config && (
|
||||
<div className="rounded-lg border border-border bg-card px-4 py-3">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
System Configuration
|
||||
{t('admin.dashboard.config')}
|
||||
</span>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Max file size:{" "}
|
||||
{t('admin.dashboard.maxFileSize')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.max_file_size_mb}MB
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Translation service:{" "}
|
||||
{t('admin.dashboard.translationService')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.translation_service}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Formats:{" "}
|
||||
{t('admin.dashboard.formats')}{" "}
|
||||
<strong className="text-foreground">
|
||||
{data.config.supported_extensions.join(", ")}
|
||||
</strong>
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function AdminSettingsPage() {
|
||||
) : (
|
||||
<RefreshCw className="size-3" />
|
||||
)}
|
||||
<span className="ml-1">Récupérer les modèles</span>
|
||||
<span className="ms-1">Récupérer les modèles</span>
|
||||
</Button>
|
||||
</div>
|
||||
{ollamaModels.length > 0 ? (
|
||||
@@ -471,7 +471,7 @@ export default function AdminSettingsPage() {
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
{model.size > 0 && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
({(model.size / 1e9).toFixed(1)} GB)
|
||||
</span>
|
||||
)}
|
||||
@@ -660,13 +660,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{testResults.smtp === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResults.smtp === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResults.smtp === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
|
||||
@@ -752,13 +752,13 @@ export default function AdminSettingsPage() {
|
||||
className="h-8"
|
||||
>
|
||||
{isSendingTestEmail ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Envoi...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
|
||||
) : testEmailResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />Envoyé</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
|
||||
) : testEmailResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Échec</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Échec</>
|
||||
) : (
|
||||
<><Mail className="size-3 mr-1" />Envoyer un email de test</>
|
||||
<><Mail className="size-3 me-1" />Envoyer un email de test</>
|
||||
)}
|
||||
</Button>
|
||||
{testEmailMessage && (
|
||||
@@ -775,12 +775,12 @@ export default function AdminSettingsPage() {
|
||||
<Button onClick={saveConfig} disabled={isSaving} size="lg">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
<Loader2 className="me-2 size-4 animate-spin" />
|
||||
Sauvegarde...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 size-4" />
|
||||
<Save className="me-2 size-4" />
|
||||
Sauvegarder la configuration
|
||||
</>
|
||||
)}
|
||||
@@ -838,13 +838,13 @@ function ProviderCard({
|
||||
className="h-8"
|
||||
>
|
||||
{testResult === "testing" ? (
|
||||
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
|
||||
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
||||
) : testResult === "ok" ? (
|
||||
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
|
||||
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
||||
) : testResult === "error" ? (
|
||||
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
|
||||
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
||||
) : (
|
||||
<><FlaskConical className="size-3 mr-1" />Tester</>
|
||||
<><FlaskConical className="size-3 me-1" />Tester</>
|
||||
)}
|
||||
</Button>
|
||||
<Switch checked={enabled} onCheckedChange={onToggle} />
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Settings, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Settings, AlertCircle, Loader2, RotateCcw, CheckCircle2 } from "lucide-react";
|
||||
import { useSystemPage } from "./useSystemPage";
|
||||
import { CleanupSection } from "./CleanupSection";
|
||||
import { DiskSpaceCard } from "./DiskSpaceCard";
|
||||
import { ProviderStatus } from "../ProviderStatus";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
|
||||
const {
|
||||
data, isLoading, error,
|
||||
isPurging, purgeResult, handleCleanup,
|
||||
isResetting, resetResult, handleResetQuotas,
|
||||
} = useSystemPage();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -16,9 +24,9 @@ export default function AdminSystemPage() {
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Système</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.system.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Surveiller l'état du système et gérer les ressources
|
||||
{t('admin.system.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +40,7 @@ export default function AdminSystemPage() {
|
||||
|
||||
{isLoading && !data ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
|
||||
@@ -52,6 +60,43 @@ export default function AdminSystemPage() {
|
||||
purgeResult={purgeResult}
|
||||
onCleanup={handleCleanup}
|
||||
/>
|
||||
|
||||
{/* Reset Translation Quotas */}
|
||||
<Card className="py-0">
|
||||
<CardContent className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/10">
|
||||
<RotateCcw className="size-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('admin.system.quotas')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{t('admin.system.resetQuotas')}
|
||||
</span>
|
||||
{resetResult && (
|
||||
<span className="text-[10px] text-green-500 flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
{resetResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 border-amber-200/30 text-amber-600 hover:bg-amber-500/10 hover:text-amber-600 text-xs"
|
||||
onClick={handleResetQuotas}
|
||||
disabled={isResetting}
|
||||
>
|
||||
{isResetting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3" />
|
||||
)}
|
||||
{isResetting ? t('admin.system.resetting') : t('admin.system.reset')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAdminDashboard } from "../useAdminDashboard";
|
||||
import { useCleanup } from "../useCleanup";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
|
||||
export function useSystemPage() {
|
||||
const { data, isLoading, error } = useAdminDashboard();
|
||||
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
|
||||
const { settings } = useTranslationStore();
|
||||
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [resetResult, setResetResult] = useState<{ keys_deleted: number; message: string } | null>(null);
|
||||
|
||||
const handleCleanup = () => triggerCleanup();
|
||||
|
||||
const handleResetQuotas = async () => {
|
||||
if (!settings.adminToken) return;
|
||||
setIsResetting(true);
|
||||
setResetResult(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/admin/quota/reset`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${settings.adminToken}` },
|
||||
});
|
||||
const json = await res.json();
|
||||
setResetResult(json);
|
||||
} catch {
|
||||
setResetResult({ keys_deleted: 0, message: "Erreur réseau" });
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
@@ -16,5 +41,8 @@ export function useSystemPage() {
|
||||
isPurging,
|
||||
purgeResult,
|
||||
handleCleanup,
|
||||
isResetting,
|
||||
resetResult,
|
||||
handleResetQuotas,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const statusConfig: Record<string, { label: string; dotClass: string; textClass:
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("fr-FR", {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -208,7 +208,7 @@ export function UserTable({
|
||||
placeholder="Rechercher par email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
className="h-8 ps-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@ export function UserTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TableHead className="h-8 ps-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Email
|
||||
</TableHead>
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
@@ -238,7 +238,7 @@ export function UserTable({
|
||||
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Clés
|
||||
</TableHead>
|
||||
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TableHead className="h-8 pe-6 text-end text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -255,7 +255,7 @@ export function UserTable({
|
||||
|
||||
return (
|
||||
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
|
||||
<TableCell className="pl-6 py-2">
|
||||
<TableCell className="ps-6 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{user.email}
|
||||
@@ -357,7 +357,7 @@ export function UserTable({
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="pr-6 py-2 text-right">
|
||||
<TableCell className="pe-6 py-2 text-end">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRevokeApiKey } from "./useRevokeApiKey";
|
||||
import { UserStats } from "./UserStats";
|
||||
import { UserTable } from "./UserTable";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { PlanType } from "./types";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
@@ -16,19 +17,20 @@ export default function AdminUsersPage() {
|
||||
const { revokeKey, isRevoking } = useRevokeApiKey();
|
||||
const { resetPassword, isResetting } = useAdminResetPassword();
|
||||
const toast = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleTierChange = async (userId: string, plan: PlanType) => {
|
||||
try {
|
||||
await updateTier({ userId, plan });
|
||||
toast.success({
|
||||
title: "Plan mis à jour",
|
||||
description: `Le plan a été changé vers "${plan}" avec succès.`,
|
||||
title: t('admin.users.planUpdated'),
|
||||
description: t('admin.users.planChanged', { plan }),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de mettre à jour le plan: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.planUpdateError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -37,8 +39,8 @@ export default function AdminUsersPage() {
|
||||
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
|
||||
if (!keyIds || keyIds.length === 0) {
|
||||
toast.warning({
|
||||
title: "Aucune clé",
|
||||
description: "Cet utilisateur n'a pas de clés API actives.",
|
||||
title: t('admin.users.noKeys'),
|
||||
description: t('admin.users.noKeysDesc'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -50,15 +52,15 @@ export default function AdminUsersPage() {
|
||||
)
|
||||
);
|
||||
toast.success({
|
||||
title: "Clés révoquées",
|
||||
description: `${keyIds.length} clé${keyIds.length > 1 ? "s" : ""} API ${keyIds.length > 1 ? "ont été révoquées" : "a été révoquée"} avec succès.`,
|
||||
title: t('admin.users.keysRevoked'),
|
||||
description: t('admin.users.keysRevokedDesc', { count: keyIds.length }),
|
||||
});
|
||||
refetch();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
const message = err instanceof Error ? err.message : t('admin.users.unknownError');
|
||||
toast.error({
|
||||
title: "Erreur",
|
||||
description: `Impossible de révoquer les clés: ${message}`,
|
||||
title: t('admin.users.error'),
|
||||
description: t('admin.users.revokeError', { message }),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
@@ -72,8 +74,8 @@ export default function AdminUsersPage() {
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
|
||||
@@ -82,7 +84,7 @@ export default function AdminUsersPage() {
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 text-xs text-destructive hover:underline"
|
||||
>
|
||||
Réessayer
|
||||
{t('admin.users.retry')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,8 +98,8 @@ export default function AdminUsersPage() {
|
||||
<Users className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
|
||||
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,19 +8,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email) {
|
||||
setError('Veuillez entrer votre adresse email');
|
||||
setError(t('forgotPassword.enterEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +31,7 @@ function ForgotPasswordForm() {
|
||||
await apiClient.post('/api/v1/auth/forgot-password', { email });
|
||||
setSent(true);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
|
||||
const message = err instanceof Error ? err.message : t('forgotPassword.error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -44,15 +46,15 @@ function ForgotPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">Mot de passe oublie</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">{t('forgotPassword.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
{sent
|
||||
? 'Verifiez votre boite mail'
|
||||
: 'Entrez votre email pour recevoir un lien de reinitialisation'}
|
||||
? t('forgotPassword.checkEmail')
|
||||
: t('forgotPassword.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -62,7 +64,7 @@ function ForgotPasswordForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye.
|
||||
{t('forgotPassword.sentMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,11 +77,11 @@ function ForgotPasswordForm() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Adresse email</Label>
|
||||
<Label htmlFor="email">{t('forgotPassword.emailLabel')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="vous@exemple.com"
|
||||
placeholder={t('forgotPassword.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
@@ -98,11 +100,11 @@ function ForgotPasswordForm() {
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Envoi en cours...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('forgotPassword.sending')}
|
||||
</>
|
||||
) : (
|
||||
'Envoyer le lien de reinitialisation'
|
||||
t('forgotPassword.sendLink')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -111,7 +113,7 @@ function ForgotPasswordForm() {
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Retour a la connexion
|
||||
{t('forgotPassword.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -120,6 +122,7 @@ function ForgotPasswordForm() {
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -128,7 +131,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('forgotPassword.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useLogin } from './useLogin';
|
||||
|
||||
export function LoginForm() {
|
||||
@@ -17,11 +18,12 @@ export function LoginForm() {
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const { notify } = useNotification();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (loginMutation.isError && loginMutation.error) {
|
||||
notify({
|
||||
title: 'Erreur de connexion',
|
||||
title: t('login.errorTitle'),
|
||||
description: loginMutation.error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -41,26 +43,26 @@ export function LoginForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Welcome back
|
||||
{t('login.welcomeBack')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to continue translating
|
||||
{t('login.signInToContinue')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t('login.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
leftIcon={<Mail className="h-4 w-4" />}
|
||||
@@ -70,16 +72,16 @@ export function LoginForm() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('login.password')}</Label>
|
||||
<Link href="/auth/forgot-password" className="text-sm text-primary hover:underline">
|
||||
Forgot password?
|
||||
{t('login.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
leftIcon={<Lock className="h-4 w-4" />}
|
||||
@@ -102,22 +104,22 @@ export function LoginForm() {
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('login.signingIn')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
{t('login.signIn')}
|
||||
<ArrowRight className="ms-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
{t('login.noAccount')}{' '}
|
||||
<Link href="/auth/register" className="text-primary hover:underline">
|
||||
Sign up for free
|
||||
{t('login.signUpFree')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { LoginForm } from './LoginForm';
|
||||
import { Loader2, Languages } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -11,7 +15,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,13 +30,13 @@ export default function LoginPage() {
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-pulse" />
|
||||
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function useLogin() {
|
||||
|
||||
return useMutation<LoginResponse, ApiClientError, LoginRequest>({
|
||||
mutationFn: async (credentials: LoginRequest) => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
const response = await apiClient.post<{ data: LoginResponse; meta: Record<string, unknown> }>(
|
||||
'/api/v1/auth/login',
|
||||
credentials
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -34,7 +35,7 @@ function validatePassword(password: string) {
|
||||
}
|
||||
|
||||
function getPasswordStrength(password: string) {
|
||||
if (password.length === 0) return { score: 0, label: '', color: '' };
|
||||
if (password.length === 0) return { score: 0, labelKey: '', color: '' };
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
@@ -43,9 +44,9 @@ function getPasswordStrength(password: string) {
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
|
||||
if (score <= 2) return { score, label: 'Faible', color: 'bg-destructive' };
|
||||
if (score <= 4) return { score, label: 'Moyen', color: 'bg-yellow-500' };
|
||||
return { score, label: 'Fort', color: 'bg-green-500' };
|
||||
if (score <= 2) return { score, labelKey: 'register.password.strength.weak', color: 'bg-destructive' };
|
||||
if (score <= 4) return { score, labelKey: 'register.password.strength.medium', color: 'bg-yellow-500' };
|
||||
return { score, labelKey: 'register.password.strength.strong', color: 'bg-green-500' };
|
||||
}
|
||||
|
||||
function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; onToggle: () => void; label: string }) {
|
||||
@@ -72,21 +73,22 @@ export function RegisterForm() {
|
||||
const [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
|
||||
|
||||
const registerMutation = useRegister();
|
||||
const { t } = useI18n();
|
||||
|
||||
const nameError = touched.name && name.length > 0 && name.length < 2
|
||||
? 'Le nom doit contenir au moins 2 caractères'
|
||||
? t('register.name.error')
|
||||
: undefined;
|
||||
|
||||
const emailError = touched.email && email.length > 0 && !validateEmail(email)
|
||||
? 'Adresse email invalide'
|
||||
? t('register.email.error')
|
||||
: undefined;
|
||||
|
||||
const passwordError = touched.password && password.length > 0 && !validatePassword(password)
|
||||
? 'Le mot de passe doit contenir au moins 8 caractères, une majuscule, une minuscule et un chiffre'
|
||||
? t('register.password.error')
|
||||
: undefined;
|
||||
|
||||
const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword
|
||||
? 'Les mots de passe ne correspondent pas'
|
||||
? t('register.confirmPassword.error')
|
||||
: undefined;
|
||||
|
||||
const passwordStrength = getPasswordStrength(password);
|
||||
@@ -112,7 +114,7 @@ export function RegisterForm() {
|
||||
<PasswordToggleIcon
|
||||
visible={showConfirm}
|
||||
onToggle={() => setShowConfirm(!showConfirm)}
|
||||
label={showConfirm ? 'Masquer' : 'Afficher'}
|
||||
label={showConfirm ? t('register.confirmPassword.hide') : t('register.confirmPassword.show')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -129,8 +131,8 @@ export function RegisterForm() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">Créer un compte</CardTitle>
|
||||
<CardDescription>Commencez à traduire gratuitement</CardDescription>
|
||||
<CardTitle className="text-2xl font-bold">{t('register.title')}</CardTitle>
|
||||
<CardDescription>{t('register.subtitle')}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
@@ -139,7 +141,7 @@ export function RegisterForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">
|
||||
{registerMutation.error?.message || "L'inscription a échoué"}
|
||||
{registerMutation.error?.message || t('register.error.failed')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,11 +149,11 @@ export function RegisterForm() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Label htmlFor="name">{t('register.name.label')}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Votre nom"
|
||||
placeholder={t('register.name.placeholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
|
||||
@@ -170,11 +172,11 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Adresse email</Label>
|
||||
<Label htmlFor="email">{t('register.email.label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="vous@exemple.com"
|
||||
placeholder={t('register.email.placeholder')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
|
||||
@@ -193,7 +195,7 @@ export function RegisterForm() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Mot de passe</Label>
|
||||
<Label htmlFor="password">{t('register.password.label')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -206,7 +208,7 @@ export function RegisterForm() {
|
||||
<PasswordToggleIcon
|
||||
visible={showPassword}
|
||||
onToggle={() => setShowPassword(!showPassword)}
|
||||
label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
|
||||
label={showPassword ? t('register.password.hide') : t('register.password.show')}
|
||||
/>
|
||||
}
|
||||
error={passwordError}
|
||||
@@ -230,14 +232,14 @@ export function RegisterForm() {
|
||||
))}
|
||||
</div>
|
||||
<p className={cn('text-xs', passwordStrength.score <= 2 ? 'text-destructive' : passwordStrength.score <= 4 ? 'text-muted-foreground' : 'text-green-500')}>
|
||||
Force : {passwordStrength.label}
|
||||
{t('register.password.strengthLabel')} {passwordStrength.labelKey ? t(passwordStrength.labelKey) : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Label htmlFor="confirmPassword">{t('register.confirmPassword.label')}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
@@ -263,30 +265,30 @@ export function RegisterForm() {
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Création du compte...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('register.submit.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Créer mon compte
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
<UserPlus className="me-2 h-4 w-4" />
|
||||
{t('register.submit.create')}
|
||||
<ArrowRight className="ms-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Vous avez déjà un compte ?{' '}
|
||||
{t('register.hasAccount')}{' '}
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium">
|
||||
Se connecter
|
||||
{t('register.login')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
En créant un compte, vous acceptez notre{' '}
|
||||
{t('register.terms.prefix')}{' '}
|
||||
<span className="text-muted-foreground">
|
||||
utilisation du service
|
||||
{t('register.terms.link')}
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Suspense } from 'react';
|
||||
'use client';
|
||||
|
||||
import { Languages, Loader2 } from 'lucide-react';
|
||||
import { RegisterForm } from './RegisterForm';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -11,7 +14,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,25 +24,23 @@ function LoadingFallback() {
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Fond dégradé */}
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
{/* Éléments flottants décoratifs */}
|
||||
{/* Decorative floating elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse" />
|
||||
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse" />
|
||||
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Formulaire — Suspense requis par useSearchParams() dans useRegister */}
|
||||
{/* Form — Suspense required by useSearchParams() in useRegister */}
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
<RegisterForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function useRegister() {
|
||||
mutationFn: async (data: RegisterRequest) => {
|
||||
await apiClient.post<RegisterResponse>('/api/v1/auth/register', data);
|
||||
|
||||
const loginResponse = await apiClient.post<LoginResponse>(
|
||||
const loginResponse = await apiClient.post<{ data: LoginResponse; meta: Record<string, unknown> }>(
|
||||
'/api/v1/auth/login',
|
||||
{ email: data.email, password: data.password }
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
function validatePassword(password: string) {
|
||||
return password.length >= 8
|
||||
@@ -21,6 +22,7 @@ function ResetPasswordForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const { t } = useI18n();
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
@@ -31,11 +33,11 @@ function ResetPasswordForm() {
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const passwordError = password.length > 0 && !validatePassword(password)
|
||||
? 'Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre'
|
||||
? t('resetPassword.passwordRequirements')
|
||||
: '';
|
||||
|
||||
const confirmError = confirmPassword.length > 0 && password !== confirmPassword
|
||||
? 'Les mots de passe ne correspondent pas'
|
||||
? t('resetPassword.passwordMismatch')
|
||||
: '';
|
||||
|
||||
const isFormValid = validatePassword(password) && password === confirmPassword;
|
||||
@@ -45,7 +47,7 @@ function ResetPasswordForm() {
|
||||
setError('');
|
||||
|
||||
if (!token) {
|
||||
setError('Token manquant. Veuillez utiliser le lien recu par email.');
|
||||
setError(t('resetPassword.tokenMissing'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ function ResetPasswordForm() {
|
||||
router.push('/auth/login');
|
||||
}, 3000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Une erreur s'est produite";
|
||||
const message = err instanceof Error ? err.message : t('resetPassword.error');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -75,20 +77,20 @@ function ResetPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold">Lien invalide</CardTitle>
|
||||
<CardTitle className="text-2xl font-bold">{t('resetPassword.invalidLink')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
|
||||
<p className="text-sm text-destructive">
|
||||
Ce lien de reinitialisation est invalide. Veuillez redemander un nouveau lien.
|
||||
{t('resetPassword.invalidLinkMessage')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/forgot-password" className="text-primary hover:underline font-medium">
|
||||
Redemander un lien
|
||||
{t('resetPassword.requestNewLink')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -104,17 +106,17 @@ function ResetPasswordForm() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
Office Translator
|
||||
{t('auth.brandName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{success ? 'Mot de passe reinitialise' : 'Nouveau mot de passe'}
|
||||
{success ? t('resetPassword.successTitle') : t('resetPassword.newPasswordTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{success
|
||||
? 'Vous allez etre redirige vers la connexion'
|
||||
: 'Definissez votre nouveau mot de passe'}
|
||||
? t('resetPassword.successSubtitle')
|
||||
: t('resetPassword.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
@@ -124,7 +126,7 @@ function ResetPasswordForm() {
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Votre mot de passe a ete reinitialise avec succes. Vous allez etre redirige vers la page de connexion.
|
||||
{t('resetPassword.successMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +139,7 @@ function ResetPasswordForm() {
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Nouveau mot de passe</Label>
|
||||
<Label htmlFor="password">{t('resetPassword.newPassword')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
@@ -156,12 +158,12 @@ function ResetPasswordForm() {
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? 'Masquer' : 'Afficher'} le mot de passe
|
||||
{showPassword ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
|
||||
<Label htmlFor="confirmPassword">{t('resetPassword.confirmPassword')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
@@ -180,7 +182,7 @@ function ResetPasswordForm() {
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirm ? 'Masquer' : 'Afficher'} le mot de passe
|
||||
{showConfirm ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -194,11 +196,11 @@ function ResetPasswordForm() {
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Reinitialisation...
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||
{t('resetPassword.resetting')}
|
||||
</>
|
||||
) : (
|
||||
'Reinitialiser le mot de passe'
|
||||
t('resetPassword.resetPassword')
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -207,7 +209,7 @@ function ResetPasswordForm() {
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Retour a la connexion
|
||||
{t('resetPassword.backToLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -216,6 +218,7 @@ function ResetPasswordForm() {
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||
@@ -224,7 +227,7 @@ function LoadingFallback() {
|
||||
<Languages className="h-6 w-6" />
|
||||
</div>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Chargement...</p>
|
||||
<p className="text-muted-foreground">{t('resetPassword.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Languages,
|
||||
Menu,
|
||||
X,
|
||||
ChevronLeft,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -144,13 +143,6 @@ export function DashboardHeader() {
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
{t('dashboard.sidebar.signOut')}
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4 shrink-0" />
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Languages, ChevronLeft, LogOut } from 'lucide-react';
|
||||
import { Languages, LogOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -83,17 +83,14 @@ export function DashboardSidebar() {
|
||||
{translateTier(t, user.tier)}
|
||||
</Badge>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-3 py-3 space-y-1">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.sidebar.theme')}</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -103,12 +100,6 @@ export function DashboardSidebar() {
|
||||
<LogOut className="size-3.5" />
|
||||
{t('dashboard.sidebar.signOut')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
|
||||
<Link href="/">
|
||||
<ChevronLeft className="size-3.5" />
|
||||
{t('dashboard.sidebar.backHome')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ApiKey } from './types';
|
||||
|
||||
interface ApiKeyTableProps {
|
||||
@@ -25,23 +26,8 @@ interface ApiKeyTableProps {
|
||||
isRevoking: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
const { t } = useI18n();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copyPrefix = (keyId: string, prefix: string) => {
|
||||
@@ -53,7 +39,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center">
|
||||
<p className="text-muted-foreground">No API keys yet. Generate your first key to get started.</p>
|
||||
<p className="text-muted-foreground">{t('apiKeys.generateNew')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,22 +50,22 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Name
|
||||
{t('apiKeys.table.name')}
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Key
|
||||
{t('apiKeys.table.prefix')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
|
||||
Created
|
||||
{t('apiKeys.table.created')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Last Used
|
||||
{t('apiKeys.table.lastUsed')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Uses
|
||||
{t('apiKeys.table.actions')}
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
<TableHead className="text-end text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('apiKeys.table.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -95,10 +81,10 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||
{formatDate(key.created_at)}
|
||||
{key.created_at ? new Date(key.created_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground lg:table-cell">
|
||||
{formatDate(key.last_used_at)}
|
||||
{key.last_used_at ? new Date(key.last_used_at).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) : t('apiKeys.table.never')}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -113,7 +99,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => copyPrefix(key.id, key.key_prefix)}
|
||||
aria-label="Copy key prefix"
|
||||
aria-label={t('apiKeys.table.copyPrefix')}
|
||||
>
|
||||
{copiedId === key.id ? (
|
||||
<Check className="size-3.5 text-accent" />
|
||||
@@ -123,7 +109,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{copiedId === key.id ? 'Copied!' : 'Copy prefix'}
|
||||
{copiedId === key.id ? 'Copied!' : t('apiKeys.table.copyPrefix')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -134,12 +120,12 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
size="icon-sm"
|
||||
onClick={() => onRevoke(key)}
|
||||
disabled={isRevoking}
|
||||
aria-label="Revoke key"
|
||||
aria-label={t('apiKeys.table.revokeKey')}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revoke</TooltipContent>
|
||||
<TooltipContent>{t('apiKeys.table.revoke')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { ApiKeyCreateResponse } from './types';
|
||||
|
||||
const MAX_KEY_NAME_LENGTH = 100;
|
||||
@@ -38,6 +39,7 @@ export function GenerateKeyDialog({
|
||||
isGenerating,
|
||||
maxKeysReached,
|
||||
}: GenerateKeyDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState<'name' | 'result'>('name');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
|
||||
@@ -46,24 +48,24 @@ export function GenerateKeyDialog({
|
||||
|
||||
const validation = useMemo<ValidationResult>(() => {
|
||||
const trimmedName = keyName.trim();
|
||||
|
||||
|
||||
if (trimmedName.length > MAX_KEY_NAME_LENGTH) {
|
||||
return { isValid: false, error: `Name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
|
||||
return { isValid: false, error: t('apiKeys.dialog.nameTooLong', { max: MAX_KEY_NAME_LENGTH }) };
|
||||
}
|
||||
|
||||
|
||||
if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
|
||||
return {
|
||||
isValid: false,
|
||||
error: t('apiKeys.dialog.nameInvalid'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return { isValid: true, error: null };
|
||||
}, [keyName]);
|
||||
}, [keyName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!validation.isValid) return;
|
||||
|
||||
|
||||
try {
|
||||
const result = await onGenerate(keyName.trim() || undefined);
|
||||
setGeneratedKey(result);
|
||||
@@ -96,13 +98,13 @@ export function GenerateKeyDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Maximum Keys Reached</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.maxTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have reached the maximum of 10 API keys. Please revoke an existing key before generating a new one.
|
||||
{t('apiKeys.dialog.maxDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>{t('apiKeys.dialog.close')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -118,25 +120,25 @@ export function GenerateKeyDialog({
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<DialogTitle>API Key Generated!</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.generated')}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Your new API key has been created. Copy it now - it won't be shown again.
|
||||
{t('apiKeys.dialog.generatedDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Important:</strong> This is the only time you'll see this key. Store it securely.
|
||||
<strong>{t('apiKeys.dialog.important')}</strong> {t('apiKeys.dialog.importantDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<Label htmlFor="apiKey">{t('apiKeys.dialog.apiKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="apiKey"
|
||||
@@ -153,15 +155,15 @@ export function GenerateKeyDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Name:</span> {generatedKey.name}
|
||||
<span className="font-medium">{t('apiKeys.dialog.name')}</span> {generatedKey.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>
|
||||
{copied ? 'Done' : 'I\'ve copied the key'}
|
||||
{copied ? t('apiKeys.dialog.done') : t('apiKeys.dialog.copied')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -173,18 +175,18 @@ export function GenerateKeyDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate New API Key</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.dialog.generateTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for programmatic access to the translation API.
|
||||
{t('apiKeys.dialog.generateDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">Key Name (optional)</Label>
|
||||
<Label htmlFor="keyName">{t('apiKeys.dialog.keyName')}</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder="e.g., Production, Staging"
|
||||
placeholder={t('apiKeys.dialog.keyNamePlaceholder')}
|
||||
value={keyName}
|
||||
onChange={(e) => {
|
||||
setKeyName(e.target.value);
|
||||
@@ -197,9 +199,9 @@ export function GenerateKeyDialog({
|
||||
aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A descriptive name to help you identify this key later.
|
||||
{t('apiKeys.dialog.keyNameHint')}
|
||||
{keyName.length > 0 && (
|
||||
<span className="ml-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
|
||||
<span className="ms-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
|
||||
)}
|
||||
</p>
|
||||
{touched && validation.error && (
|
||||
@@ -209,13 +211,13 @@ export function GenerateKeyDialog({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
{t('apiKeys.dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
|
||||
{isGenerating ? 'Generating...' : 'Generate Key'}
|
||||
{isGenerating ? t('apiKeys.dialog.generating') : t('apiKeys.dialog.generate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,8 +4,11 @@ import { Key, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export function ProUpgradePrompt() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-6">
|
||||
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
|
||||
@@ -13,39 +16,38 @@ export function ProUpgradePrompt() {
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
|
||||
<Key className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">API Keys</CardTitle>
|
||||
<CardTitle className="text-2xl font-semibold">{t('apiKeys.upgrade.title')}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Automate your translations with API access
|
||||
{t('apiKeys.upgrade.subtitle')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Generate unlimited API keys</span>
|
||||
<span>{t('apiKeys.upgrade.feat1')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Automate document translation</span>
|
||||
<span>{t('apiKeys.upgrade.feat2')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Webhook notifications</span>
|
||||
<span>{t('apiKeys.upgrade.feat3')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>LLM translation modes</span>
|
||||
<span>{t('apiKeys.upgrade.feat4')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
API Keys are a <span className="text-accent font-medium">Pro</span> feature.
|
||||
Upgrade to unlock API automation.
|
||||
{t('apiKeys.upgrade.proFeature', { pro: t('apiKeys.upgrade.pro') })}
|
||||
</p>
|
||||
<Button asChild className="w-full bg-accent hover:bg-accent/90">
|
||||
<Link href="/pricing">
|
||||
Upgrade to Pro
|
||||
{t('apiKeys.upgrade.cta')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface RevokeKeyDialogProps {
|
||||
open: boolean;
|
||||
@@ -26,21 +27,18 @@ export function RevokeKeyDialog({
|
||||
isRevoking,
|
||||
keyName,
|
||||
}: RevokeKeyDialogProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke API Key</DialogTitle>
|
||||
<DialogTitle>{t('apiKeys.revokeDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to revoke this API key?
|
||||
{keyName && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
"{keyName}"
|
||||
</span>
|
||||
)}
|
||||
{keyName ? t('apiKeys.revokeDialog.desc', { name: keyName }) : t('apiKeys.revokeDialog.desc', { name: '' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
@@ -52,21 +50,21 @@ export function RevokeKeyDialog({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Cancel
|
||||
{t('apiKeys.revokeDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
{isRevoking ? 'Revoking...' : t('apiKeys.table.revoke')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -15,8 +15,10 @@ import { GenerateKeyDialog } from './GenerateKeyDialog';
|
||||
import { RevokeKeyDialog } from './RevokeKeyDialog';
|
||||
import { WebhookSnippet } from './WebhookSnippet';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
keys,
|
||||
@@ -44,16 +46,15 @@ export default function ApiKeysPage() {
|
||||
// Handle API errors with specific error codes
|
||||
useEffect(() => {
|
||||
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
// Redirect to upgrade prompt will happen via isPro check
|
||||
setApiError(null);
|
||||
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
setApiError('You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.');
|
||||
setApiError(t('apiKeys.limitReachedDesc'));
|
||||
} else if (errorDetails) {
|
||||
setApiError(errorDetails.message);
|
||||
} else {
|
||||
setApiError(null);
|
||||
}
|
||||
}, [errorDetails]);
|
||||
}, [errorDetails]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleRevokeClick = (key: ApiKey) => {
|
||||
setKeyToRevoke({ id: key.id, name: key.name });
|
||||
@@ -67,22 +68,22 @@ export default function ApiKeysPage() {
|
||||
setRevokeDialogOpen(false);
|
||||
setKeyToRevoke(null);
|
||||
toast({
|
||||
title: 'Key revoked',
|
||||
description: 'The API key has been revoked successfully.',
|
||||
title: t('apiKeys.keyRevoked'),
|
||||
description: t('apiKeys.keyRevokedDesc'),
|
||||
});
|
||||
} catch (error) {
|
||||
const revokeError = parseRevokeError();
|
||||
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Key Not Found',
|
||||
description: 'The API key no longer exists. It may have already been revoked.',
|
||||
title: t('apiKeys.keyNotFound'),
|
||||
description: t('apiKeys.keyNotFoundDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: revokeError?.message || 'Failed to revoke the API key. Please try again.',
|
||||
title: t('apiKeys.error'),
|
||||
description: revokeError?.message || t('apiKeys.revokeError'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,20 +98,20 @@ export default function ApiKeysPage() {
|
||||
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Limit Reached',
|
||||
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.',
|
||||
title: t('apiKeys.limitReached'),
|
||||
description: t('apiKeys.limitReachedDesc'),
|
||||
});
|
||||
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Pro Feature Required',
|
||||
description: 'API keys are a Pro feature. Please upgrade your account.',
|
||||
title: t('apiKeys.proRequired'),
|
||||
description: t('apiKeys.proRequiredDesc'),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: genError?.message || 'Failed to generate API key. Please try again.',
|
||||
title: t('apiKeys.error'),
|
||||
description: genError?.message || t('apiKeys.generateError'),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
@@ -122,7 +123,7 @@ export default function ApiKeysPage() {
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
<p className="text-sm text-muted-foreground">{t('apiKeys.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -135,9 +136,9 @@ export default function ApiKeysPage() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('apiKeys.title')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your API keys for programmatic access to the translation API.
|
||||
{t('apiKeys.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -155,8 +156,8 @@ export default function ApiKeysPage() {
|
||||
<Zap className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">API & Automation</CardTitle>
|
||||
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription>
|
||||
<CardTitle className="text-base">{t('apiKeys.sectionTitle')}</CardTitle>
|
||||
<CardDescription>{t('apiKeys.sectionDesc')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -165,13 +166,13 @@ export default function ApiKeysPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{total} of {MAX_API_KEYS} keys used
|
||||
{t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxKeysReached ? (
|
||||
<span className="text-amber-600">Maximum keys reached. Revoke a key to generate a new one.</span>
|
||||
<span className="text-amber-600">{t('apiKeys.maxReached')}</span>
|
||||
) : (
|
||||
`You can generate ${MAX_API_KEYS - total} more key${MAX_API_KEYS - total !== 1 ? 's' : ''}.`
|
||||
t(MAX_API_KEYS - total !== 1 ? 'apiKeys.canGeneratePlural' : 'apiKeys.canGenerate', { count: MAX_API_KEYS - total })
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -181,7 +182,7 @@ export default function ApiKeysPage() {
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Generate New Key
|
||||
{t('apiKeys.generateNew')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyCreateResponse,
|
||||
ApiKeyCreateApiResponse,
|
||||
ApiKeyRevokeResponse,
|
||||
} from './types';
|
||||
|
||||
@@ -35,8 +36,7 @@ export function useApiKeys() {
|
||||
} = useQuery<ApiKeysListApiResponse, ApiClientError>({
|
||||
queryKey: API_KEYS_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
|
||||
return response.data;
|
||||
return apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 429) return false;
|
||||
@@ -49,7 +49,7 @@ export function useApiKeys() {
|
||||
|
||||
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
|
||||
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => {
|
||||
const response = await apiClient.post<ApiKeyCreateResponse>('/api/v1/api-keys', {
|
||||
const response = await apiClient.post<ApiKeyCreateApiResponse>('/api/v1/api-keys', {
|
||||
name: name || 'API Key',
|
||||
});
|
||||
return response.data;
|
||||
@@ -61,8 +61,7 @@ export function useApiKeys() {
|
||||
|
||||
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
|
||||
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
|
||||
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
|
||||
return response.data;
|
||||
return apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Key, BookText, User, type LucideIcon } from 'lucide-react';
|
||||
import { FileText, Key, BookText, User, Globe, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
labelKey: string;
|
||||
@@ -10,7 +10,8 @@ export interface NavItem {
|
||||
export const baseNavItems: NavItem[] = [
|
||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key },
|
||||
{ labelKey: 'dashboard.nav.context', href: '/dashboard/context', icon: Globe, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||
];
|
||||
|
||||
export const proNavItem: NavItem = {
|
||||
@@ -21,5 +22,6 @@ export const proNavItem: NavItem = {
|
||||
};
|
||||
|
||||
export function getNavItems(isPro: boolean): NavItem[] {
|
||||
return isPro ? [...baseNavItems, proNavItem] : baseNavItems;
|
||||
if (isPro) return [...baseNavItems, proNavItem];
|
||||
return baseNavItems.filter(item => !item.proOnly);
|
||||
}
|
||||
|
||||
195
frontend/src/app/dashboard/context/page.tsx
Normal file
195
frontend/src/app/dashboard/context/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, Zap, Crown, Lock } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PRESETS = [
|
||||
{ key: 'hvac', icon: '🔧', title: 'HVAC / Génie climatique', desc: 'Thermique, ventilation, climatisation' },
|
||||
{ key: 'construction', icon: '🏗️', title: 'BTP / Construction', desc: 'Gros œuvre, second œuvre, normes' },
|
||||
{ key: 'it', icon: '💻', title: 'IT / Logiciel', desc: 'Développement, infrastructure, DevOps' },
|
||||
{ key: 'legal', icon: '⚖️', title: 'Juridique / Contrats', desc: 'Droit des affaires, contentieux' },
|
||||
{ key: 'medical', icon: '🏥', title: 'Médical / Santé', desc: 'Pharmacologie, chirurgie, diagnostic' },
|
||||
{ key: 'finance', icon: '📊', title: 'Finance / Comptabilité', desc: 'IFRS, bilans, fiscalité' },
|
||||
{ key: 'marketing', icon: '📢', title: 'Marketing / Publicité', desc: 'Digital, branding, analytics' },
|
||||
{ key: 'automotive', icon: '🚗', title: 'Automobile', desc: 'Motorisation, ADAS, homologation' },
|
||||
];
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
const { t } = useI18n();
|
||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isPro, setIsPro] = useState(false);
|
||||
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({ systemPrompt: settings.systemPrompt, glossary: settings.glossary });
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTier = async () => {
|
||||
const isProTier = (user: any) => ['pro', 'business', 'enterprise'].includes(user?.plan ?? user?.tier ?? '');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
const user = result.data;
|
||||
setIsPro(isProTier(user));
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
checkTier();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings(localSettings);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} finally { setIsSaving(false); }
|
||||
};
|
||||
|
||||
const handleApplyPreset = (key: string) => {
|
||||
applyPreset(key);
|
||||
setTimeout(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||
glossary: useTranslationStore.getState().settings.glossary,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearContext();
|
||||
setLocalSettings({ systemPrompt: '', glossary: '' });
|
||||
};
|
||||
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 p-6">
|
||||
<div className="flex items-center justify-center w-20 h-20 rounded-2xl bg-violet-100 dark:bg-violet-900/30">
|
||||
<Crown className="w-10 h-10 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div className="text-center space-y-2 max-w-md">
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.proTitle')}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t('context.proDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/pricing">
|
||||
<Crown className="w-4 h-4 me-2" /> {t('context.viewPlans')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('context.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('context.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Zap className="w-4 h-4 text-accent" /> {t('context.presets.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.presets.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => handleApplyPreset(p.key)}
|
||||
className="flex flex-col items-center gap-1 p-3 rounded-xl border border-border text-center transition-colors hover:border-primary/40 hover:bg-primary/5"
|
||||
>
|
||||
<span className="text-2xl">{p.icon}</span>
|
||||
<span className="text-xs font-medium text-foreground leading-tight">{p.title}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight">{p.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Prompt */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Brain className="w-4 h-4 text-primary" /> {t('context.instructions.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.instructions.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={e => setLocalSettings({ ...localSettings, systemPrompt: e.target.value })}
|
||||
placeholder={t('context.instructions.placeholder')}
|
||||
className="min-h-[140px] resize-y"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Glossary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-emerald-500" /> {t('context.glossary.title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('context.glossary.desc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={localSettings.glossary}
|
||||
onChange={e => setLocalSettings({ ...localSettings, glossary: e.target.value })}
|
||||
placeholder={"pression statique=static pressure\nrécupérateur de chaleur=heat recovery unit"}
|
||||
className="min-h-[250px] resize-y font-mono text-sm"
|
||||
/>
|
||||
{localSettings.glossary && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{localSettings.glossary.split('\n').filter(l => l.includes('=')).length} {t('context.glossary.terms')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="ghost" onClick={handleClear} className="text-destructive hover:text-destructive/80 hover:bg-destructive/10">
|
||||
<Trash2 className="me-1.5 h-4 w-4" /> {t('context.clearAll')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? <><Loader2 className="me-1.5 h-4 w-4 animate-spin" />{t('context.saving')}</> : <><Save className="me-1.5 h-4 w-4" />{t('context.save')}</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ function TemplateCard({
|
||||
onClick={() => onSelect(template)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'flex flex-col gap-2 rounded-lg border p-3 text-start transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
colorClass
|
||||
)}
|
||||
>
|
||||
@@ -477,7 +477,7 @@ export function CreateGlossaryDialog({
|
||||
{t('glossaries.dialog.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isProcessing && <Loader2 className="size-3.5 animate-spin mr-1.5" />}
|
||||
{isProcessing && <Loader2 className="size-3.5 animate-spin me-1.5" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BookText, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryListItem } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useI18n, formatDate } from '@/lib/i18n';
|
||||
|
||||
interface GlossaryCardProps {
|
||||
glossary: GlossaryListItem;
|
||||
@@ -30,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
|
||||
onDelete(glossary.id, glossary.name);
|
||||
}, [glossary.id, glossary.name, onDelete]);
|
||||
|
||||
const formattedDate = new Date(glossary.created_at).toLocaleDateString(locale === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
const formattedDate = formatDate(new Date(glossary.created_at), locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -60,8 +60,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
} = useQuery<GlossaryListResponse, ApiClientError>({
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
|
||||
return response.data;
|
||||
return apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 401) return false;
|
||||
@@ -80,7 +79,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -90,7 +89,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
|
||||
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -125,7 +124,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>(
|
||||
`/api/v1/glossaries/import?${params.toString()}`
|
||||
);
|
||||
return response.data.data;
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
@@ -191,8 +190,7 @@ export function useGlossaryTemplates() {
|
||||
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
|
||||
queryKey: ['glossary-templates'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
|
||||
return response.data;
|
||||
return apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // templates rarely change
|
||||
retry: 1,
|
||||
@@ -214,8 +212,7 @@ export function useGlossary(id: string | null) {
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, id],
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error('Glossary ID is required');
|
||||
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
|
||||
return response.data;
|
||||
return apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: (failureCount, err) => {
|
||||
|
||||
@@ -4,67 +4,59 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n, type Locale } from '@/lib/i18n';
|
||||
import { useI18n, type Locale, formatDate } from '@/lib/i18n';
|
||||
import {
|
||||
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
|
||||
FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
|
||||
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
|
||||
BadgeCheck, ShieldAlert, Info, Globe,
|
||||
BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { languages } from '@/lib/api';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ─────────────── helpers ─────────────── */
|
||||
/* ── helpers ──────────────────────────────────────────────────── */
|
||||
const PLAN_ICONS: Record<string, React.ElementType> = {
|
||||
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
|
||||
};
|
||||
|
||||
const PLAN_COLORS: Record<string, { badge: string; gradient: string; ring: string }> = {
|
||||
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
|
||||
free: { badge: 'bg-muted text-muted-foreground border border-border', gradient: 'from-slate-600 to-slate-700', ring: 'ring-slate-500/30' },
|
||||
starter: { badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200', gradient: 'from-blue-600 to-blue-700', ring: 'ring-blue-500/30' },
|
||||
pro: { badge: 'bg-violet-100 text-violet-700 dark:bg-violet-900/50 dark:text-violet-200', gradient: 'from-violet-600 to-violet-700', ring: 'ring-violet-500/30' },
|
||||
business: { badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-200', gradient: 'from-emerald-600 to-emerald-700', ring: 'ring-emerald-500/30' },
|
||||
enterprise: { badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-200', gradient: 'from-amber-600 to-amber-700', ring: 'ring-amber-500/30' },
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: 'Gratuit', starter: 'Starter', pro: 'Pro', business: 'Business', enterprise: 'Entreprise',
|
||||
};
|
||||
|
||||
const PLAN_PRICES: Record<string, number> = {
|
||||
free: 0, starter: 9, pro: 19, business: 49,
|
||||
free: 'profile.plan.free', starter: 'profile.plan.starter', pro: 'profile.plan.pro', business: 'profile.plan.business', enterprise: 'profile.plan.enterprise',
|
||||
};
|
||||
const PLAN_PRICES: Record<string, number> = { free: 0, starter: 9, pro: 19, business: 49 };
|
||||
|
||||
function getInitials(name?: string) {
|
||||
if (!name) return '??';
|
||||
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||
}
|
||||
|
||||
function pct(used: number, limit: number) {
|
||||
if (limit === -1 || limit === 0) return 0;
|
||||
return Math.min(100, Math.round((used / limit) * 100));
|
||||
}
|
||||
|
||||
function fmtLimit(val: number) {
|
||||
return val === -1 ? '∞' : String(val);
|
||||
}
|
||||
|
||||
function nextResetDate() {
|
||||
function fmtLimit(val: number) { return val === -1 ? '∞' : String(val); }
|
||||
function nextResetDate(locale: Locale) {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return next.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
|
||||
return formatDate(next, locale, { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
interface UsageBarProps {
|
||||
label: string;
|
||||
used: number;
|
||||
limit: number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
|
||||
const p = pct(used, limit);
|
||||
const isUnlimited = limit === -1;
|
||||
return (
|
||||
@@ -81,13 +73,7 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700',
|
||||
p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${p}%` }}
|
||||
/>
|
||||
<div className={cn('h-full rounded-full transition-all duration-700', p >= 90 ? 'bg-red-500' : p >= 70 ? 'bg-amber-500' : 'bg-primary')} style={{ width: `${p}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,14 +81,26 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
|
||||
}
|
||||
|
||||
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
|
||||
{ value: 'es', label: 'Español', flag: '🇪🇸' },
|
||||
{ value: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
{ value: 'pt', label: 'Português', flag: '🇧🇷' },
|
||||
{ value: 'it', label: 'Italiano', flag: '🇮🇹' },
|
||||
{ value: 'nl', label: 'Nederlands', flag: '🇳🇱' },
|
||||
{ value: 'ru', label: 'Русский', flag: '🇷🇺' },
|
||||
{ value: 'ja', label: '日本語', flag: '🇯🇵' },
|
||||
{ value: 'ko', label: '한국어', flag: '🇰🇷' },
|
||||
{ value: 'zh', label: '中文', flag: '🇨🇳' },
|
||||
{ value: 'ar', label: 'العربية', flag: '🇸🇦' },
|
||||
{ value: 'fa', label: 'فارسی', flag: '🇮🇷' },
|
||||
];
|
||||
|
||||
/* ─────────────── Main component ─────────────── */
|
||||
/* ── Main component ───────────────────────────────────────────── */
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
const { locale, setLocale } = useI18n();
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [usage, setUsage] = useState<any>(null);
|
||||
@@ -111,6 +109,8 @@ export default function ProfilePage() {
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [loadingPortal, setLoadingPortal] = useState(false);
|
||||
const [loadingCancel, setLoadingCancel] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
const authHeaders = { Authorization: `Bearer ${token}` };
|
||||
@@ -124,14 +124,11 @@ export default function ProfilePage() {
|
||||
]);
|
||||
if (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
|
||||
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
|
||||
|
||||
const handleBillingPortal = async () => {
|
||||
setLoadingPortal(true);
|
||||
@@ -140,32 +137,43 @@ export default function ProfilePage() {
|
||||
const j = await res.json();
|
||||
const url = j.data?.url ?? j.url;
|
||||
if (url) window.open(url, '_blank');
|
||||
else setStatusMsg({ type: 'err', text: 'Portail de facturation non disponible (Stripe non configuré).' });
|
||||
} catch {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'accès au portail.' });
|
||||
} finally {
|
||||
setLoadingPortal(false);
|
||||
}
|
||||
else setStatusMsg({ type: 'err', text: t('profile.subscription.billingUnavailable') });
|
||||
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.billingError') }); }
|
||||
finally { setLoadingPortal(false); }
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!cancelConfirm) { setCancelConfirm(true); return; }
|
||||
setLoadingCancel(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, {
|
||||
method: 'POST', headers: authHeaders,
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { method: 'POST', headers: authHeaders });
|
||||
if (res.ok) {
|
||||
setStatusMsg({ type: 'ok', text: 'Abonnement annulé. Vous conservez l\'accès jusqu\'à la fin de la période en cours.' });
|
||||
setStatusMsg({ type: 'ok', text: t('profile.subscription.cancelSuccess') });
|
||||
setCancelConfirm(false);
|
||||
fetchData();
|
||||
} else {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' });
|
||||
} else { setStatusMsg({ type: 'err', text: t('profile.subscription.cancelError') }); }
|
||||
} catch { setStatusMsg({ type: 'err', text: t('profile.subscription.networkError') }); }
|
||||
finally { setLoadingCancel(false); }
|
||||
};
|
||||
|
||||
const handleSavePrefs = () => {
|
||||
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||
};
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
localStorage.removeItem('translation-settings');
|
||||
sessionStorage.clear();
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
} catch {
|
||||
setStatusMsg({ type: 'err', text: 'Erreur réseau.' });
|
||||
} finally {
|
||||
setLoadingCancel(false);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -178,7 +186,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
const planId = user?.plan ?? user?.tier ?? 'free';
|
||||
const planLabel = PLAN_LABELS[planId] ?? planId;
|
||||
const planLabel = t(PLAN_LABELS[planId] ?? planId);
|
||||
const planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
|
||||
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
|
||||
const planPrice = PLAN_PRICES[planId];
|
||||
@@ -193,15 +201,15 @@ export default function ProfilePage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
|
||||
{/* ── Page title ── */}
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Mon Profil</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Gérez votre compte, votre abonnement et votre utilisation.</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Status message ── */}
|
||||
{/* Status message */}
|
||||
{statusMsg && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-3 p-4 rounded-xl border text-sm',
|
||||
@@ -209,73 +217,68 @@ export default function ProfilePage() {
|
||||
? 'bg-success/10 border-success/30 text-success'
|
||||
: 'bg-destructive/10 border-destructive/30 text-destructive'
|
||||
)}>
|
||||
{statusMsg.type === 'ok'
|
||||
? <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
: <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
|
||||
{statusMsg.type === 'ok' ? <CheckCircle2 className="w-4 h-4 shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
||||
<span className="flex-1">{statusMsg.text}</span>
|
||||
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground">✕</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Two-column grid on desktop ── */}
|
||||
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="account" className="w-full">
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="account" className="gap-1.5"><User className="size-3.5" /> {t('profile.tabs.account')}</TabsTrigger>
|
||||
<TabsTrigger value="subscription" className="gap-1.5"><CreditCard className="size-3.5" /> {t('profile.tabs.subscription')}</TabsTrigger>
|
||||
<TabsTrigger value="preferences" className="gap-1.5"><Settings className="size-3.5" /> {t('profile.tabs.preferences')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── LEFT column ── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Identity card */}
|
||||
{/* ── Tab: Account ────────────────────────────────────── */}
|
||||
<TabsContent value="account" className="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center w-16 h-16 rounded-2xl text-white font-bold text-xl ring-4',
|
||||
`bg-gradient-to-br ${planColors.gradient}`,
|
||||
planColors.ring
|
||||
'flex shrink-0 items-center justify-center w-20 h-20 rounded-2xl text-white font-bold text-2xl ring-4',
|
||||
`bg-gradient-to-br ${planColors.gradient}`, planColors.ring
|
||||
)}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground truncate">{user?.name || 'Utilisateur'}</h2>
|
||||
<h2 className="text-xl font-semibold text-foreground truncate">{user?.name || t('profile.account.user')}</h2>
|
||||
<Badge className={cn('text-xs font-medium', planColors.badge)}>
|
||||
<PlanIcon className="w-3 h-3 mr-1" />
|
||||
{planLabel}
|
||||
<PlanIcon className="w-3 h-3 me-1" />{planLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Mail className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{user?.email}</span>
|
||||
</div>
|
||||
{user?.created_at && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Calendar className="w-3 h-3 shrink-0" />
|
||||
<span>Membre depuis le {new Date(user.created_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}</span>
|
||||
<span>{t('profile.account.memberSince')} {formatDate(new Date(user.created_at), locale)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Subscription card */}
|
||||
{/* ── Tab: Subscription ───────────────────────────────── */}
|
||||
<TabsContent value="subscription" className="space-y-6 pt-4">
|
||||
|
||||
{/* Plan card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CreditCard className="w-4 h-4 text-primary" />
|
||||
Mon abonnement
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}>
|
||||
<PlanIcon className="w-5 h-5 text-white" />
|
||||
<div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
|
||||
<PlanIcon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">Forfait {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
|
||||
</p>
|
||||
<p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isFreePlan && (
|
||||
@@ -285,168 +288,89 @@ export default function ProfilePage() {
|
||||
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
|
||||
'text-destructive border-destructive/30 bg-destructive/10'
|
||||
)}>
|
||||
{isCanceling ? 'Annulation en cours' :
|
||||
user?.subscription_status === 'active' ? 'Actif' :
|
||||
user?.subscription_status ?? 'Inconnu'}
|
||||
{isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isFreePlan && user?.subscription_ends_at && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm">
|
||||
<Info className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<Info className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
{isCanceling
|
||||
? `Accès jusqu'au ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
: `Renouvellement le ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`}
|
||||
? `${t('profile.subscription.accessUntil')} ${formatDate(new Date(user.subscription_ends_at), locale)}`
|
||||
: `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild size="sm" className="gap-2">
|
||||
<Link href="/pricing">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
{isFreePlan ? 'Passer à un forfait payant' : 'Changer de forfait'}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm"><Link href="/pricing"><TrendingUp className="w-3.5 h-3.5 me-1.5" />{isFreePlan ? t('profile.subscription.upgradePlan') : t('profile.subscription.changePlan')}</Link></Button>
|
||||
{!isFreePlan && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleBillingPortal}
|
||||
disabled={loadingPortal}
|
||||
>
|
||||
{loadingPortal
|
||||
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
: <ExternalLink className="w-3.5 h-3.5" />}
|
||||
Gérer la facturation
|
||||
<Button variant="outline" size="sm" onClick={handleBillingPortal} disabled={loadingPortal}>
|
||||
{loadingPortal ? <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" /> : <ExternalLink className="w-3.5 h-3.5 me-1.5" />}
|
||||
{t('profile.subscription.manageBilling')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger zone */}
|
||||
{!isFreePlan && !isCanceling && (
|
||||
<Card className="border-red-900/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
Zone danger
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
L'annulation prend effet à la fin de votre période de facturation en cours. Vous conservez l'accès jusqu'à cette date.
|
||||
</p>
|
||||
{cancelConfirm && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
||||
⚠️ Êtes-vous sûr(e) ? Cette action ne peut pas être annulée une fois la période terminée.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={loadingCancel}
|
||||
className="gap-2"
|
||||
>
|
||||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
{cancelConfirm ? "Confirmer l'annulation" : 'Annuler mon abonnement'}
|
||||
</Button>
|
||||
{cancelConfirm && (
|
||||
<Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>
|
||||
Non, garder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── RIGHT column ── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* Usage card */}
|
||||
{/* Usage */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<BadgeCheck className="w-4 h-4 text-primary" />
|
||||
Utilisation ce mois-ci
|
||||
<span className="ml-auto text-xs text-muted-foreground font-normal">
|
||||
Réinitialisation le {nextResetDate()}
|
||||
</span>
|
||||
{t('profile.usage.title')}
|
||||
<span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<UsageBar
|
||||
label="Documents traduits"
|
||||
used={docsUsed}
|
||||
limit={docsLimit}
|
||||
icon={<FileText className="w-4 h-4" />}
|
||||
/>
|
||||
<UsageBar
|
||||
label="Pages traduites"
|
||||
used={pagesUsed}
|
||||
limit={pagesLimit}
|
||||
icon={<Layers className="w-4 h-4" />}
|
||||
/>
|
||||
|
||||
<UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
|
||||
<UsageBar label={t('profile.usage.pages')} used={pagesUsed} limit={pagesLimit} icon={<Layers className="w-4 h-4" />} />
|
||||
{extraCredits > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-50 border border-amber-200 dark:bg-amber-500/10 dark:border-amber-500/20 text-sm">
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
|
||||
</span>
|
||||
<Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage?.upgrade_required && (
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-red-50 border border-red-200 dark:bg-red-500/10 dark:border-red-500/20 text-sm">
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">Quota atteint</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">Passez à un forfait supérieur pour continuer à traduire.</p>
|
||||
<p className="text-red-700 dark:text-red-300 font-medium">{t('profile.usage.quotaReached')}</p>
|
||||
<p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0">
|
||||
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 shrink-0"><Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link></Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFreePlan && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20 text-sm">
|
||||
<Zap className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<span className="text-primary flex-1">Débloquez plus de traductions avec un forfait payant.</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 flex-shrink-0">
|
||||
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link>
|
||||
<Zap className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-primary flex-1">{t('profile.usage.unlockMore')}</span>
|
||||
<Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
|
||||
<Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Features included */}
|
||||
{/* Features */}
|
||||
{user?.plan_limits?.features?.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
|
||||
Inclus dans votre forfait
|
||||
{t('profile.usage.includedInPlan')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{user.plan_limits.features.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f}</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -454,43 +378,124 @@ export default function ProfilePage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Preferences */}
|
||||
{/* Danger zone */}
|
||||
{!isFreePlan && !isCanceling && (
|
||||
<Card className="border-red-900/30">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<ShieldAlert className="w-4 h-4" /> {t('profile.danger.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('profile.danger.description')}
|
||||
</p>
|
||||
{cancelConfirm && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/30 text-sm text-destructive">
|
||||
⚠️ {t('profile.danger.confirm')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" size="sm" onClick={handleCancel} disabled={loadingCancel}>
|
||||
{loadingCancel && <RefreshCw className="w-3.5 h-3.5 me-1.5 animate-spin" />}
|
||||
{cancelConfirm ? t('profile.danger.confirmCancel') : t('profile.danger.cancelSubscription')}
|
||||
</Button>
|
||||
{cancelConfirm && <Button variant="outline" size="sm" onClick={() => setCancelConfirm(false)}>{t('profile.danger.keep')}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Tab: Preferences ────────────────────────────────── */}
|
||||
<TabsContent value="preferences" className="space-y-6 pt-4">
|
||||
|
||||
{/* Interface language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-primary" />
|
||||
Préférences
|
||||
<Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Langue de l'interface</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Choisissez la langue d'affichage</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
|
||||
locale === lang.value
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.label}</span>
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{UI_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => setLocale(lang.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||||
locale === lang.value
|
||||
? 'bg-primary/10 border-primary/40 text-primary'
|
||||
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
|
||||
)}
|
||||
>
|
||||
<span>{lang.flag}</span><span>{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default target language */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-primary" /> {t('profile.prefs.defaultTargetLang')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger><SelectValue placeholder={t('profile.prefs.selectLanguage')} /></SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
<span className="flex items-center gap-2"><span>{lang.flag}</span><span>{lang.name}</span></span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('profile.prefs.defaultTargetLangDesc')}</p>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSavePrefs}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 me-1.5" /> {t('profile.prefs.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/* Theme */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-primary" /> {t('profile.prefs.theme')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.themeDesc')}</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache */}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('profile.prefs.cache')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">{t('profile.prefs.cacheDesc')}</p>
|
||||
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('profile.prefs.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('profile.prefs.clearCache')}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from "lucide-react";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const FALLBACK_PROVIDERS = [
|
||||
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
|
||||
@@ -19,6 +20,7 @@ interface AvailableProvider {
|
||||
}
|
||||
|
||||
export default function TranslationServicesPage() {
|
||||
const { t } = useI18n();
|
||||
const [providers, setProviders] = useState<AvailableProvider[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -49,27 +51,26 @@ export default function TranslationServicesPage() {
|
||||
const llmProviders = providers.filter((p) => p.mode === "llm");
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-2xl">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-2xl font-bold">Translation Providers</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('services.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Providers are configured by the administrator. You can see which ones are
|
||||
currently available for your account.
|
||||
{t('services.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading providers…</span>
|
||||
<span>{t('services.loading')}</span>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No providers are currently configured. Contact your administrator.
|
||||
{t('services.noProviders')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -79,7 +80,7 @@ export default function TranslationServicesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Classic Translation
|
||||
{t('services.classic')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -87,12 +88,11 @@ export default function TranslationServicesPage() {
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||
<div>
|
||||
<p className="font-medium">{p.label}</p>
|
||||
<p className="font-medium text-foreground">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Available
|
||||
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
|
||||
<CheckCircle2 className="size-3" />{t('services.available')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -106,7 +106,7 @@ export default function TranslationServicesPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
LLM · Context-Aware (Pro)
|
||||
{t('services.llmPro')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -114,17 +114,16 @@ export default function TranslationServicesPage() {
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between py-4 px-5">
|
||||
<div>
|
||||
<p className="font-medium">{p.label}</p>
|
||||
<p className="font-medium text-foreground">{p.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{p.description}</p>
|
||||
{p.model && (
|
||||
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
|
||||
Modèle : {p.model}
|
||||
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80">
|
||||
{t('services.model')}: {p.model}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
|
||||
<CheckCircle2 className="size-3" />
|
||||
Available
|
||||
<Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
|
||||
<CheckCircle2 className="size-3" />{t('services.available')}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -135,17 +134,16 @@ export default function TranslationServicesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<Card className="border-amber-200 dark:border-amber-500/20 bg-amber-50 dark:bg-amber-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-amber-800 flex items-center gap-2">
|
||||
<CardTitle className="text-sm text-amber-800 dark:text-amber-400 flex items-center gap-2">
|
||||
<Lock className="size-4" />
|
||||
Provider configuration is admin-only
|
||||
{t('services.adminOnly.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-amber-700 text-xs">
|
||||
API keys, model selection, and provider activation are managed exclusively
|
||||
by the administrator in the admin panel. You never need to enter an API key.
|
||||
<CardDescription className="text-amber-700 dark:text-amber-400/80 text-xs">
|
||||
{t('services.adminOnly.desc')}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
95
frontend/src/app/dashboard/settings/page.tsx
Normal file
95
frontend/src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Settings, Trash2, Loader2, FileSpreadsheet, FileText, Presentation,
|
||||
} from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
localStorage.removeItem('translation-settings');
|
||||
sessionStorage.clear();
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ icon: <FileSpreadsheet className="w-6 h-6 text-green-500" />, name: 'Excel', ext: '.xlsx, .xls', features: [t('settings.formats.formulas'), t('settings.formats.styles'), t('settings.formats.images')] },
|
||||
{ icon: <FileText className="w-6 h-6 text-blue-500" />, name: 'Word', ext: '.docx, .doc', features: [t('settings.formats.headers'), t('settings.formats.tables'), t('settings.formats.images')] },
|
||||
{ icon: <Presentation className="w-6 h-6 text-orange-500" />, name: 'PowerPoint', ext: '.pptx, .ppt', features: [t('settings.formats.slides'), t('settings.formats.notes'), t('settings.formats.images')] },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:p-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>{t('settings.formats.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.formats.subtitle')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{formats.map((f) => (
|
||||
<div key={f.name} className="flex items-start gap-3 p-3 rounded-lg border border-border">
|
||||
<div className="p-2 rounded-lg bg-muted">{f.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-sm">{f.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{f.ext}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{f.features.map(ft => (
|
||||
<Badge key={ft} variant="outline" className="text-[10px] px-1.5 py-0">{ft}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger zone */}
|
||||
<Card className="border-border/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" /> {t('settings.cache.title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">{t('settings.cache.desc')}</p>
|
||||
<Button onClick={handleClearCache} disabled={isClearing} variant="outline" size="sm" className="shrink-0">
|
||||
{isClearing ? <><Loader2 className="me-1.5 h-3.5 w-3.5 animate-spin" />{t('settings.cache.clearing')}</> : <><Trash2 className="me-1.5 h-3.5 w-3.5" />{t('settings.cache.clear')}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,24 +57,32 @@ export function FileDropZone({ upload }: FileDropZoneProps) {
|
||||
|
||||
{/* Format badges */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileSpreadsheet className="size-3.5 text-green-500" />
|
||||
Excel (.xlsx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileText className="size-3.5 text-blue-500" />
|
||||
Word (.docx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<FileSpreadsheet className="size-3.5 text-green-500" />
|
||||
Excel (.xlsx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Presentation className="size-3.5 text-orange-500" />
|
||||
PowerPoint (.pptx)
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 rounded-lg border border-border/60 bg-background px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<svg className="size-3.5 text-red-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<text x="12" y="16" textAnchor="middle" fontSize="5" fontWeight="bold" fill="currentColor" stroke="none">PDF</text>
|
||||
</svg>
|
||||
PDF (.pdf)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-label={t('dashboard.translate.dropzone.uploadAria')}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
className="ms-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Language } from './types';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
|
||||
onTargetChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
sourceLang,
|
||||
targetLang,
|
||||
languages,
|
||||
isLoading,
|
||||
error,
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
/* ── Combobox dropdown with search ──────────────────────────────── */
|
||||
function Combobox({
|
||||
value,
|
||||
options,
|
||||
includeAuto,
|
||||
autoLabel,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Language[];
|
||||
includeAuto: boolean;
|
||||
autoLabel: string;
|
||||
placeholder: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
if (open) document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
const allOptions = includeAuto
|
||||
? [{ code: 'auto', name: autoLabel }, ...options]
|
||||
: options;
|
||||
|
||||
const q = query.toLowerCase().trim();
|
||||
const filtered = q
|
||||
? allOptions.filter(l => l.name.toLowerCase().includes(q) || l.code.toLowerCase().includes(q))
|
||||
: allOptions;
|
||||
|
||||
const label = value === 'auto' ? autoLabel : allOptions.find(l => l.code === value)?.name ?? value;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border px-3 py-2 text-sm transition-colors',
|
||||
open
|
||||
? 'border-primary ring-2 ring-primary/15 outline-none'
|
||||
: 'border-border bg-background hover:border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-foreground">{label || placeholder}</span>
|
||||
<ChevronDown className={cn('size-4 shrink-0 text-muted-foreground transition-transform ms-2', open && 'rotate-180')} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-lg border border-border bg-popover shadow-md">
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[200px] overflow-y-auto p-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results</div>
|
||||
)}
|
||||
{filtered.map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => { onChange(lang.code); setOpen(false); setQuery(''); }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||
value === lang.code
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="flex-1 text-start">{lang.name}</span>
|
||||
{value === lang.code && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main component ─────────────────────────────────────────────── */
|
||||
export default function LanguageSelector({
|
||||
sourceLang, targetLang, languages, isLoading, error,
|
||||
onSourceChange, onTargetChange,
|
||||
}: LanguageSelectorProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span className="text-xs">{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canSwap = sourceLang !== 'auto';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={sourceLang}
|
||||
options={languages}
|
||||
includeAuto
|
||||
autoLabel={t('dashboard.translate.language.autoDetect')}
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onSourceChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Source language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.source')}
|
||||
</label>
|
||||
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t('dashboard.translate.language.autoDetect')}</SelectItem>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Swap */}
|
||||
<div className="flex justify-center -my-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
|
||||
disabled={!canSwap}
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
|
||||
canSwap
|
||||
? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
|
||||
: 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
|
||||
)}
|
||||
title="Inverser"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Arrow — flips in RTL via rtl: variant */}
|
||||
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<ArrowRight className="size-4 rtl:rotate-180" />
|
||||
</div>
|
||||
|
||||
{/* Target language */}
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}>
|
||||
<SelectTrigger className="h-11 w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>{t('dashboard.translate.language.loading')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('dashboard.translate.language.selectPlaceholder')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Target */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('dashboard.translate.language.target')}
|
||||
</label>
|
||||
<Combobox
|
||||
value={targetLang}
|
||||
options={languages}
|
||||
includeAuto={false}
|
||||
autoLabel=""
|
||||
placeholder={t('dashboard.translate.language.selectPlaceholder')}
|
||||
onChange={onTargetChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Download, Plus, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2, Download, Plus, Loader2, FileText,
|
||||
Timer, Activity, TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TranslationCompleteProps {
|
||||
jobId: string;
|
||||
@@ -91,54 +95,78 @@ export function TranslationComplete({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
{/* Success icon */}
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-green-500/15">
|
||||
<CheckCircle2 className="size-10 text-green-500" />
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
{/* ═══ Success header ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-8 py-6 text-center">
|
||||
<div className="mx-auto flex size-16 items-center justify-center rounded-2xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-8 text-white" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-bold text-foreground">
|
||||
{t('dashboard.translate.complete.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{fileName
|
||||
? t('dashboard.translate.complete.descNamed', { name: fileName })
|
||||
: t('dashboard.translate.complete.descGeneric')}
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" /> Haute qualité
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions — stacked column so text is never cut */}
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 w-full gap-2 text-base font-semibold"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.complete.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-5" />
|
||||
{t('dashboard.translate.complete.download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="p-8 space-y-6">
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
onClick={onNewTranslation}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
{/* ═══ Result stats ═══ */}
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<FileText className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">142</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Segments</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Activity className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-foreground">12.8k</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Caractères</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-emerald-100 bg-emerald-50/50 p-3 dark:border-emerald-900/30 dark:bg-emerald-950/10">
|
||||
<Timer className="size-4 text-emerald-600" />
|
||||
<p className="text-sm font-bold text-emerald-600">96%</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Confiance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Actions ═══ */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-12 w-full gap-2 text-base font-semibold"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.complete.downloading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="size-5" />
|
||||
{t('dashboard.translate.complete.download')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
onClick={onNewTranslation}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ export function TranslationModeToggle({
|
||||
onClick={() => onModeChange('classic')}
|
||||
>
|
||||
Classic
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
Fast
|
||||
</span>
|
||||
</button>
|
||||
@@ -58,7 +58,7 @@ export function TranslationModeToggle({
|
||||
disabled={!isPro}
|
||||
>
|
||||
Pro LLM
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
<span className="ms-1.5 text-xs text-muted-foreground">
|
||||
Context-Aware
|
||||
</span>
|
||||
{!isPro && (
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertTriangle, Loader2, Clock, WifiOff,
|
||||
Upload, Search, Languages, Wrench, CheckCircle2,
|
||||
FileText, Timer, Gauge, Activity,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
/* ── Types ──────────────────────────────────────────────────────── */
|
||||
interface TranslationProgressProps {
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
@@ -13,8 +19,84 @@ interface TranslationProgressProps {
|
||||
isPolling?: boolean;
|
||||
isUploading?: boolean;
|
||||
isCompleted?: boolean;
|
||||
/** Extra info for the monitor panel */
|
||||
fileName?: string | null;
|
||||
sourceLang?: string;
|
||||
targetLang?: string;
|
||||
providerLabel?: string | null;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/* ── Pipeline step definition ───────────────────────────────────── */
|
||||
interface PipelineStep {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
/** Progress range where this step is active */
|
||||
startsAt: number;
|
||||
}
|
||||
|
||||
const PIPELINE_STEPS: PipelineStep[] = [
|
||||
{ id: 'upload', label: 'Upload', icon: Upload, startsAt: 0 },
|
||||
{ id: 'analyze', label: 'Analyse', icon: Search, startsAt: 10 },
|
||||
{ id: 'translate', label: 'Traduction', icon: Languages, startsAt: 25 },
|
||||
{ id: 'rebuild', label: 'Reconstruction', icon: Wrench, startsAt: 75 },
|
||||
{ id: 'finalize', label: 'Finalisation', icon: CheckCircle2, startsAt: 92 },
|
||||
];
|
||||
|
||||
/* ── Simulated activity messages ────────────────────────────────── */
|
||||
const ACTIVITY_MESSAGES: Record<string, string[]> = {
|
||||
upload: [
|
||||
'Fichier reçu avec succès',
|
||||
'Vérification du format...',
|
||||
],
|
||||
analyze: [
|
||||
'Analyse de la structure du document',
|
||||
'Extraction des segments de texte',
|
||||
'Détection de la langue source',
|
||||
],
|
||||
translate: [
|
||||
'Connexion au moteur de traduction',
|
||||
'Traduction des segments en cours...',
|
||||
'Traitement des tableaux et graphiques',
|
||||
'Conservation de la mise en forme',
|
||||
'Validation des traductions',
|
||||
],
|
||||
rebuild: [
|
||||
'Reconstruction du document traduit',
|
||||
'Application de la mise en forme originale',
|
||||
'Vérification de l\'intégrité',
|
||||
],
|
||||
finalize: [
|
||||
'Contrôle qualité final',
|
||||
'Préparation du téléchargement',
|
||||
],
|
||||
};
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||
function getActiveStepIndex(progress: number): number {
|
||||
let idx = 0;
|
||||
for (let i = PIPELINE_STEPS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STEPS[i].startsAt) { idx = i; break; }
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60) return `~${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `~${m}m${s > 0 ? ` ${s}s` : ''}`;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
export function TranslationProgress({
|
||||
progress,
|
||||
currentStep,
|
||||
@@ -23,101 +105,254 @@ export function TranslationProgress({
|
||||
isPolling = true,
|
||||
isUploading = false,
|
||||
isCompleted = false,
|
||||
fileName,
|
||||
sourceLang,
|
||||
targetLang,
|
||||
providerLabel,
|
||||
onCancel,
|
||||
}: TranslationProgressProps) {
|
||||
const { t } = useI18n();
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const prevProgressRef = useRef(progress);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [activities, setActivities] = useState<{ text: string; time: string }[]>([]);
|
||||
const prevStepIdx = useRef(-1);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Animate progress bar on first progress
|
||||
useEffect(() => {
|
||||
if (progress > 0) {
|
||||
setAnimate(true);
|
||||
} else if (progress === 0) {
|
||||
if (progress > 0) setAnimate(true);
|
||||
else {
|
||||
setAnimate(false);
|
||||
const tid = setTimeout(() => setAnimate(true), 50);
|
||||
return () => clearTimeout(tid);
|
||||
}
|
||||
prevProgressRef.current = progress;
|
||||
}, [progress]);
|
||||
|
||||
function formatTimeRemaining(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60)
|
||||
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) });
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
if (remaining === 0)
|
||||
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) });
|
||||
return t('dashboard.translate.progress.timeMixed', {
|
||||
minutes: String(minutes),
|
||||
seconds: String(remaining),
|
||||
});
|
||||
}
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (isCompleted || error) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
return;
|
||||
}
|
||||
elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
|
||||
return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
|
||||
}, [isCompleted, error]);
|
||||
|
||||
// Activity log — push messages when step changes
|
||||
const activeIdx = getActiveStepIndex(progress);
|
||||
useEffect(() => {
|
||||
if (activeIdx !== prevStepIdx.current) {
|
||||
prevStepIdx.current = activeIdx;
|
||||
const step = PIPELINE_STEPS[activeIdx];
|
||||
if (!step) return;
|
||||
const msgs = ACTIVITY_MESSAGES[step.id] || [];
|
||||
msgs.forEach((msg, i) => {
|
||||
setTimeout(() => {
|
||||
setActivities(prev => {
|
||||
const next = [{ text: msg, time: formatElapsed(elapsed) }, ...prev];
|
||||
return next.slice(0, 10);
|
||||
});
|
||||
}, i * 600);
|
||||
});
|
||||
}
|
||||
}, [activeIdx, elapsed]);
|
||||
|
||||
// Simulated stats
|
||||
const totalSegments = 142;
|
||||
const totalChars = 12847;
|
||||
const doneSeg = Math.round(totalSegments * progress / 100);
|
||||
const doneChars = Math.round(totalChars * progress / 100);
|
||||
const speed = elapsed > 3 ? (doneSeg / (elapsed / 60)).toFixed(1) : '—';
|
||||
|
||||
/* ── Error state ──────────────────────────────────────────────── */
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl bg-destructive/10 border border-destructive/20 p-5"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" aria-hidden />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">
|
||||
{t('dashboard.translate.progress.failedTitle')}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} className="mx-auto flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.actions.tryAgain')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeRemaining = formatTimeRemaining(estimatedRemaining);
|
||||
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 py-4">
|
||||
{/* Animated spinner */}
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Loader2 className="size-8 animate-spin text-primary" aria-hidden />
|
||||
<div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ═══ Header band ═══ */}
|
||||
<div className="relative overflow-hidden border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">Traduction en cours</h2>
|
||||
{fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{estimatedRemaining != null && estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
{formatTime(estimatedRemaining)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
<p className="text-base font-medium text-foreground">
|
||||
{currentStep || t('dashboard.translate.progress.processingFallback')}
|
||||
</p>
|
||||
{timeRemaining && (
|
||||
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="size-3.5" aria-hidden />
|
||||
{timeRemaining}
|
||||
</p>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
|
||||
{/* ═══ Pipeline Stepper ═══ */}
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEPS.map((step, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = step.icon;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEPS.length - 1 ? 1 : 'none' }}>
|
||||
{/* Step circle + connector */}
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Icon className={cn('size-4', isActive && 'animate-pulse')} />
|
||||
)}
|
||||
</div>
|
||||
{i < PIPELINE_STEPS.length - 1 && (
|
||||
<div className={cn(
|
||||
'mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500',
|
||||
i < activeIdx ? 'bg-primary' : 'bg-border',
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium transition-colors duration-300',
|
||||
isDone || isActive ? 'text-primary' : 'text-muted-foreground',
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ═══ Progress bar ═══ */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{currentStep || t('dashboard.translate.progress.processingFallback')}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-700 ease-out',
|
||||
animate && 'bg-gradient-to-r from-primary via-primary/80 to-primary',
|
||||
)}
|
||||
style={{ width: `${Math.max(0, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ Live Stats Grid ═══ */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatCard
|
||||
icon={<FileText className="size-4" />}
|
||||
value={`${doneSeg}/${totalSegments}`}
|
||||
label="Segments"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Activity className="size-4" />}
|
||||
value={doneChars.toLocaleString()}
|
||||
label="Caractères"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Gauge className="size-4" />}
|
||||
value={speed}
|
||||
label="Seg/min"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Timer className="size-4" />}
|
||||
value={formatElapsed(elapsed)}
|
||||
label="Écoulé"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ═══ Activity Feed ═══ */}
|
||||
{activities.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Activity className="size-3" /> Journal d'activité
|
||||
</p>
|
||||
<div className="max-h-[100px] overflow-y-auto rounded-xl border border-border bg-muted/30">
|
||||
{activities.map((act, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 border-b border-border/50 px-3 py-1.5 last:border-0">
|
||||
<div className="size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span className="flex-1 text-xs text-foreground">{act.text}</span>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">{act.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Connection lost ═══ */}
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ Cancel button ═══ */}
|
||||
{onCancel && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex items-center gap-2 rounded-lg border border-destructive/20 px-4 py-2 text-sm font-medium text-destructive transition hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
>
|
||||
<RotateCcw className="size-4" />Annuler
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar + percentage */}
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Progress
|
||||
value={progress}
|
||||
animate={animate}
|
||||
className="h-3 rounded-full"
|
||||
aria-label={t('dashboard.translate.progress.ariaProgress')}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
<p className="text-end text-sm font-semibold tabular-nums text-primary" aria-live="polite">
|
||||
{Math.round(progress)} %
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
<WifiOff className="size-3.5" aria-hidden />
|
||||
<span>{t('dashboard.translate.progress.connectionLost')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Stat card sub-component ────────────────────────────────────── */
|
||||
function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
Presentation,
|
||||
Upload,
|
||||
X,
|
||||
ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
|
||||
FileSpreadsheet, FileText, Presentation, Upload, X,
|
||||
Zap, Brain, CheckCircle2, ArrowLeftRight,
|
||||
Search, Languages, Wrench, Activity, Gauge, Timer,
|
||||
Download, Plus, TrendingUp, AlertTriangle, FileType,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
import { LanguageSelector } from './LanguageSelector';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { TranslationProgress } from './TranslationProgress';
|
||||
import { TranslationComplete } from './TranslationComplete';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
|
||||
};
|
||||
const FILE_COLORS: Record<string, string> = {
|
||||
xlsx: 'text-green-500',
|
||||
docx: 'text-blue-500',
|
||||
pptx: 'text-orange-500',
|
||||
xlsx: 'text-green-500', docx: 'text-blue-500', pptx: 'text-orange-500', pdf: 'text-red-500',
|
||||
};
|
||||
|
||||
function fmt(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/* ── Compact file strip ──────────────────────────────────────────── */
|
||||
function FileStrip({
|
||||
file,
|
||||
onRemove,
|
||||
onReplace,
|
||||
}: {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
onReplace: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReplace}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
{t('dashboard.translate.dropzone.replaceFile')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
onClick={onRemove}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
/* ── Quality label based on provider ────────────────────────────── */
|
||||
function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
|
||||
if (!provider) return t('dashboard.translate.highQuality');
|
||||
if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
|
||||
if (provider === 'deepl') return t('dashboard.translate.highQuality');
|
||||
return t('dashboard.translate.quality');
|
||||
}
|
||||
|
||||
/* ── Trust row ───────────────────────────────────────────────────── */
|
||||
function TrustRow() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
{t('dashboard.translate.trust.zeroRetention')}
|
||||
</span>
|
||||
<span className="h-3 w-px bg-border" aria-hidden />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
{t('dashboard.translate.trust.deletedAfter')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
/* ── Pipeline step keys ─────────────────────────────────────────── */
|
||||
const PIPELINE_STEP_KEYS = [
|
||||
'dashboard.translate.pipeline.upload',
|
||||
'dashboard.translate.pipeline.analyze',
|
||||
'dashboard.translate.pipeline.translate',
|
||||
'dashboard.translate.pipeline.rebuild',
|
||||
'dashboard.translate.pipeline.finalize',
|
||||
] as const;
|
||||
|
||||
const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
|
||||
const PIPELINE_STARTS = [0, 10, 25, 75, 92];
|
||||
|
||||
function getActiveStepIdx(progress: number) {
|
||||
for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
|
||||
if (progress >= PIPELINE_STARTS[i]) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number) {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
@@ -112,201 +76,528 @@ export default function TranslatePage() {
|
||||
const { t } = useI18n();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: t('dashboard.translate.errorNotificationTitle'),
|
||||
description: submit.error,
|
||||
});
|
||||
showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
|
||||
}
|
||||
}, [submit.error, showError, t]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if ((submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed') {
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
}
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [submit.status, submit.isSubmitting]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
await submit.submitTranslation(upload.file, config.getConfig());
|
||||
const cfg = config.getConfig();
|
||||
if (isPdf) cfg.pdfMode = pdfMode;
|
||||
await submit.submitTranslation(upload.file, cfg);
|
||||
};
|
||||
|
||||
const handleNewTranslation = () => {
|
||||
submit.reset();
|
||||
upload.removeFile();
|
||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
|
||||
const handleDownload = async () => {
|
||||
if (!submit.jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${submit.jobId}`, { headers });
|
||||
if (!response.ok) return;
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (match?.[1]) downloadFilename = match[1];
|
||||
} else if (submit.fileName) {
|
||||
const ext = submit.fileName.split('.').pop() || '';
|
||||
const base = submit.fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${base}_translated.${ext}`;
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = downloadFilename;
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
/* ── Derived states ──────────────────────────────────────────── */
|
||||
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing =
|
||||
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
/* ── STATE: No file ─────────────────────────────────────────────── */
|
||||
if (!upload.file && !isProcessing && !isCompleted && !isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
<TrustRow />
|
||||
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
||||
const showConfiguring = isConfiguring;
|
||||
const showProcessing = isProcessing;
|
||||
const showComplete = isCompleted && !!submit.jobId;
|
||||
const showFailed = isFailed;
|
||||
|
||||
const currentProvider = config.availableProviders.find(p => p.id === config.provider);
|
||||
const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
|
||||
const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
|
||||
const activeStepIdx = getActiveStepIdx(submit.progress);
|
||||
const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
/* UNIFIED LAYOUT — always the same grid */
|
||||
/* ═══════════════════════════════════════════════════════════════ */
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.pageSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Configure — single column, full width ───────────────── */
|
||||
if (isConfiguring) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-1 flex-col gap-6 p-6 lg:p-8">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">
|
||||
{t('dashboard.translate.pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
{/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* File strip */}
|
||||
<FileStrip
|
||||
file={upload.file!}
|
||||
onRemove={upload.removeFile}
|
||||
onReplace={() => replaceInputRef.current?.click()}
|
||||
/>
|
||||
<input
|
||||
ref={replaceInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
/>
|
||||
{upload.error && <p className="text-sm text-destructive">{upload.error}</p>}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* LEFT CARD (2/3) — content swaps based on state */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
|
||||
{/* Language selector — full width */}
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
languages={config.languages}
|
||||
isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError}
|
||||
onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
{/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
|
||||
{showUpload && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="flex-1 px-6 pb-6">
|
||||
<FileDropZone upload={upload} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Provider selector — full width */}
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
{/* ── CONFIGURING STATE: File strip ──────────────────── */}
|
||||
{showConfiguring && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
|
||||
</div>
|
||||
<div className="px-6 pb-6">
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
||||
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── PROCESSING STATE: Rich progress ────────────────── */}
|
||||
{showProcessing && (
|
||||
<div className="flex flex-col">
|
||||
{/* Header band */}
|
||||
<div className="border-b border-border bg-gradient-to-r from-primary/5 via-primary/8 to-primary/3 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.translating')}</h2>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[300px]">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{submit.estimatedRemaining != null && submit.estimatedRemaining > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-xs font-semibold text-primary">
|
||||
<Clock className="size-3.5" />
|
||||
~{submit.estimatedRemaining}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Pipeline stepper */}
|
||||
<PipelineStepper activeIdx={activeStepIdx} t={t} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium text-foreground animate-pulse truncate">
|
||||
{submit.currentStep || (submit.isSubmitting ? t('dashboard.translate.steps.uploading') : t('dashboard.translate.steps.starting'))}
|
||||
</p>
|
||||
<p className="text-2xl font-black tabular-nums tracking-tight text-primary shrink-0">
|
||||
{Math.round(submit.progress)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-3 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-primary via-primary/80 to-primary transition-all duration-700 ease-out"
|
||||
style={{ width: `${Math.max(0, submit.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live stats */}
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
<StatBox icon={<FileText className="size-4" />} value={`${Math.round(submit.progress)}%`} label={t('dashboard.translate.segments')} />
|
||||
<StatBox icon={<Activity className="size-4" />} value={t('dashboard.translate.translating')} label={t('dashboard.translate.characters')} />
|
||||
<StatBox icon={<Gauge className="size-4" />} value="—" label={t('dashboard.translate.segPerMin')} />
|
||||
<StatBox icon={<Timer className="size-4" />} value={formatElapsed(elapsed)} label={t('dashboard.translate.elapsed')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── COMPLETE STATE: Success ────────────────────────── */}
|
||||
{showComplete && (
|
||||
<div className="flex flex-col">
|
||||
{/* Success header */}
|
||||
<div className="border-b border-emerald-200/50 bg-gradient-to-r from-emerald-500/8 via-emerald-500/5 to-transparent px-6 py-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
||||
<CheckCircle2 className="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground leading-tight">{t('dashboard.translate.completed')}</h2>
|
||||
{submit.fileName && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate max-w-[260px]">{submit.fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-400">
|
||||
<TrendingUp className="size-3" />{qualityLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 p-6">
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button size="lg" className="h-11 gap-2 flex-1 font-semibold" onClick={handleDownload}>
|
||||
<Download className="size-4" />{t('dashboard.translate.complete.download')}
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="h-11 gap-2 flex-1" onClick={handleNewTranslation}>
|
||||
<Plus className="size-4" />{t('dashboard.translate.complete.newTranslation')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── FAILED STATE ───────────────────────────────────── */}
|
||||
{showFailed && (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4" role="alert">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
|
||||
<p className="text-sm text-destructive/80">{submit.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky bottom bar: button + trust */}
|
||||
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-13 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
{t('dashboard.translate.actions.uploading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('dashboard.translate.actions.translate')}
|
||||
<ArrowRight className="size-5 rtl:rotate-180" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mt-3">
|
||||
<TrustRow />
|
||||
</div>
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
{/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
|
||||
|
||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||
{(showUpload || showConfiguring || showFailed) && (
|
||||
<>
|
||||
<div className="px-6 pt-6 pb-4">
|
||||
<h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-5 px-6">
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang} targetLang={config.targetLang}
|
||||
languages={config.languages} isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError} onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
|
||||
<ProviderSelector
|
||||
provider={config.provider} onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders} isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
|
||||
{/* PDF mode selector — only shown for PDF files */}
|
||||
{isPdf && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{t('dashboard.translate.pdfMode.title')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('layout')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'layout'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<FileText className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.preserveLayout')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.preserveLayoutDesc')}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPdfMode('text_only')}
|
||||
className={cn(
|
||||
'flex flex-col items-start rounded-lg border-2 p-3 text-start transition-all',
|
||||
pdfMode === 'text_only'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border hover:border-primary/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Languages className="size-4 text-primary" />
|
||||
{t('dashboard.translate.pdfMode.textOnly')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{t('dashboard.translate.pdfMode.textOnlyDesc')}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with button */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
size="lg" className="h-12 w-full gap-2 text-base font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<><Loader2 className="size-5 animate-spin" />{t('dashboard.translate.actions.uploading')}</>
|
||||
) : (
|
||||
<>{t('dashboard.translate.actions.translate')}<ArrowRight className="size-5 rtl:rotate-180" /></>
|
||||
)}
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5"><ShieldCheck className="size-3.5" />{t('dashboard.translate.trust.zeroRetention')}</span>
|
||||
<span className="h-3 w-px bg-border" aria-hidden />
|
||||
<span className="flex items-center gap-1.5"><Clock className="size-3.5" />{t('dashboard.translate.trust.deletedAfter')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── MONITOR (processing) ────────────────────────────── */}
|
||||
{showProcessing && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<div className="size-2 animate-pulse rounded-full bg-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">{t('dashboard.translate.liveMonitor')}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
{/* File summary */}
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<div className="flex items-center gap-3 rounded-xl bg-primary/5 border border-primary/10 p-3">
|
||||
{(() => {
|
||||
const name = submit.fileName || upload.file?.name || '';
|
||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return <FileIcon className={`size-6 shrink-0 ${color}`} />;
|
||||
})()}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{submit.fileName || upload.file?.name}</p>
|
||||
{upload.file && <p className="text-[11px] text-muted-foreground">{fmt(upload.file.size)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config summary */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quality indicator */}
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-700"
|
||||
style={{ width: `${Math.min(95, 40 + submit.progress * 0.55)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="px-6 py-4 border-t border-border/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-destructive/20 text-destructive hover:bg-destructive hover:text-white hover:border-destructive"
|
||||
onClick={handleNewTranslation}
|
||||
>
|
||||
<RotateCcw className="size-4" />{t('dashboard.translate.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── SUMMARY (complete) ──────────────────────────────── */}
|
||||
{showComplete && (
|
||||
<>
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />{t('dashboard.translate.summary')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 space-y-2.5">
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.source')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{srcLangName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.language.target')}</span>
|
||||
<span className="text-xs font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{tgtLangName}</span>
|
||||
</div>
|
||||
{currentProvider && (
|
||||
<div className="flex items-center justify-between py-1.5 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.engine')}</span>
|
||||
<span className="text-xs font-semibold text-foreground bg-muted px-2 py-0.5 rounded">{currentProvider.label}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('dashboard.translate.quality')}</span>
|
||||
<span className="text-xs font-bold text-emerald-600">{qualityLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality bar */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-xl border border-border bg-muted/20 p-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-emerald-100 dark:bg-emerald-900/30">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: '95%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Processing ──────────────────────────────────────────── */
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
{(submit.fileName || upload.file?.name) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('dashboard.translate.actions.filePrefix')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{submit.fileName || upload.file?.name}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={
|
||||
submit.currentStep ||
|
||||
(submit.isSubmitting
|
||||
? t('dashboard.translate.steps.uploading')
|
||||
: t('dashboard.translate.steps.starting'))
|
||||
}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={null}
|
||||
isPolling={submit.isPolling}
|
||||
isUploading={submit.isSubmitting}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Complete ────────────────────────────────────────────── */
|
||||
if (isCompleted && submit.jobId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center overflow-y-auto p-6">
|
||||
<TranslationComplete
|
||||
jobId={submit.jobId}
|
||||
fileName={submit.fileName}
|
||||
onNewTranslation={handleNewTranslation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── STATE: Failed ──────────────────────────────────────────────── */
|
||||
if (isFailed) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={submit.error}
|
||||
isPolling={false}
|
||||
isCompleted={false}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2">
|
||||
<RotateCcw className="size-4" />
|
||||
{t('dashboard.translate.actions.tryAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══ Sub-components ═══════════════════════════════════════════════ */
|
||||
|
||||
/** Compact file strip */
|
||||
function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||
const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<FileIcon className={`size-5 shrink-0 ${color}`} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
|
||||
</div>
|
||||
<button type="button" onClick={onReplace} className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground transition hover:bg-secondary hover:text-foreground">
|
||||
<Upload className="size-3.5" />{t('dashboard.translate.replace')}
|
||||
</button>
|
||||
<button type="button" aria-label="Remove" onClick={onRemove} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-secondary hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pipeline stepper */
|
||||
function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
{PIPELINE_STEP_KEYS.map((stepKey, i) => {
|
||||
const isActive = i === activeIdx;
|
||||
const isDone = i < activeIdx;
|
||||
const Icon = PIPELINE_ICONS[i];
|
||||
return (
|
||||
<div key={stepKey} className="flex flex-col items-center gap-1.5 relative" style={{ flex: i < PIPELINE_STEP_KEYS.length - 1 ? 1 : 'none' }}>
|
||||
<div className="flex items-center w-full">
|
||||
<div className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
|
||||
isDone
|
||||
? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
|
||||
: isActive
|
||||
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}>
|
||||
{isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
|
||||
</div>
|
||||
{i < PIPELINE_STEP_KEYS.length - 1 && (
|
||||
<div className={cn('mx-1 h-[2px] flex-1 rounded-full transition-colors duration-500', i < activeIdx ? 'bg-primary' : 'bg-border')} />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{t(stepKey)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small stat box */
|
||||
function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
|
||||
<div className="text-primary">{icon}</div>
|
||||
<p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface TranslationConfig {
|
||||
targetLang: string;
|
||||
mode: TranslationMode;
|
||||
provider?: Provider;
|
||||
pdfMode?: 'layout' | 'text_only';
|
||||
}
|
||||
|
||||
export interface UseTranslationConfigReturn {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf',
|
||||
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AvailableProvider,
|
||||
} from './types';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
|
||||
/** Fallback when API fails — Google is always available server-side */
|
||||
const FALLBACK_PROVIDERS: AvailableProvider[] = [
|
||||
@@ -59,8 +60,9 @@ const FALLBACK_LANGUAGES: Language[] = [
|
||||
];
|
||||
|
||||
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
|
||||
const { settings } = useTranslationStore();
|
||||
const [sourceLang, setSourceLang] = useState('auto');
|
||||
const [targetLang, setTargetLang] = useState('');
|
||||
const [targetLang, setTargetLang] = useState(settings.defaultTargetLanguage || '');
|
||||
const [provider, setProvider] = useState<Provider | null>(null);
|
||||
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
|
||||
@@ -69,6 +71,13 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
|
||||
const [languagesError, setLanguagesError] = useState<string | null>(null);
|
||||
|
||||
// Sync with store default target language
|
||||
useEffect(() => {
|
||||
if (settings.defaultTargetLanguage && !targetLang) {
|
||||
setTargetLang(settings.defaultTargetLanguage);
|
||||
}
|
||||
}, [settings.defaultTargetLanguage]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch available (admin-configured) providers
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -105,6 +114,16 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
return () => { controller.abort(); clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
// Auto-select first classic provider for non-Pro users
|
||||
useEffect(() => {
|
||||
if (isLoadingProviders) return;
|
||||
if (provider !== null) return;
|
||||
if (isPro) return;
|
||||
if (availableProviders.length === 0) return;
|
||||
const firstClassic = availableProviders.find((p) => p.mode === 'classic');
|
||||
if (firstClassic) setProvider(firstClassic.id);
|
||||
}, [availableProviders, isLoadingProviders, isPro, provider]);
|
||||
|
||||
// Fetch supported languages
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -148,11 +167,12 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
// Check user tier
|
||||
useEffect(() => {
|
||||
const checkTier = async () => {
|
||||
const isProTier = (u: any) => ['pro', 'business', 'enterprise'].includes(u?.plan ?? u?.tier ?? '');
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
|
||||
if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
try {
|
||||
@@ -164,7 +184,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const user = result.data;
|
||||
setIsPro(user.tier === 'pro');
|
||||
setIsPro(isProTier(user));
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
setIsPro(false);
|
||||
|
||||
@@ -62,6 +62,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
setError('Translation job not found');
|
||||
return;
|
||||
}
|
||||
// 429 (rate-limited) is not a real failure — just skip this poll
|
||||
if (response.status === 429) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -135,6 +139,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
if (config.mode === 'llm' && config.provider) {
|
||||
formData.append('provider', config.provider);
|
||||
}
|
||||
// PDF mode: layout (preserve layout) or text_only (clean text output)
|
||||
if (config.pdfMode) {
|
||||
formData.append('pdf_mode', config.pdfMode);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useUser(): UseQueryResult<User, ApiClientError> {
|
||||
return useQuery({
|
||||
queryKey: ['user', 'me'],
|
||||
queryFn: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/api/v1/auth/me');
|
||||
const response = await apiClient.get<{ data: User; meta: Record<string, unknown> }>('/api/v1/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html suppressHydrationWarning>
|
||||
<body className={`${inter.className} bg-background text-foreground antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
|
||||
<I18nProvider>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Types
|
||||
@@ -50,11 +51,13 @@ interface CreditPackage {
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Static plan data (fallback + SSR-friendly)
|
||||
Names, descriptions, features, badges use i18n keys
|
||||
resolved via t() in the render layer.
|
||||
───────────────────────────────────────────── */
|
||||
const STATIC_PLANS: Plan[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Gratuit",
|
||||
name: "pricing.plans.free.name",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
docs_per_month: 5,
|
||||
@@ -63,21 +66,21 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 50_000,
|
||||
providers: ["google"],
|
||||
features: [
|
||||
"5 documents / mois",
|
||||
"Jusqu'à 15 pages par document",
|
||||
"Google Traduction inclus",
|
||||
"Toutes les langues (130+)",
|
||||
"Support communautaire",
|
||||
"pricing.plans.free.feat1",
|
||||
"pricing.plans.free.feat2",
|
||||
"pricing.plans.free.feat3",
|
||||
"pricing.plans.free.feat4",
|
||||
"pricing.plans.free.feat5",
|
||||
],
|
||||
ai_translation: false,
|
||||
api_access: false,
|
||||
priority_processing: false,
|
||||
popular: false,
|
||||
description: "Parfait pour découvrir l'application",
|
||||
description: "pricing.plans.free.description",
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
name: "pricing.plans.starter.name",
|
||||
price_monthly: 9.00,
|
||||
price_yearly: 86.40,
|
||||
docs_per_month: 50,
|
||||
@@ -86,22 +89,22 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 500_000,
|
||||
providers: ["google", "deepl"],
|
||||
features: [
|
||||
"50 documents / mois",
|
||||
"Jusqu'à 50 pages par document",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 10 Mo",
|
||||
"Support par e-mail",
|
||||
"Historique 30 jours",
|
||||
"pricing.plans.starter.feat1",
|
||||
"pricing.plans.starter.feat2",
|
||||
"pricing.plans.starter.feat3",
|
||||
"pricing.plans.starter.feat4",
|
||||
"pricing.plans.starter.feat5",
|
||||
"pricing.plans.starter.feat6",
|
||||
],
|
||||
ai_translation: false,
|
||||
api_access: false,
|
||||
priority_processing: false,
|
||||
popular: false,
|
||||
description: "Pour les particuliers et petits projets",
|
||||
description: "pricing.plans.starter.description",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
name: "pricing.plans.pro.name",
|
||||
price_monthly: 19.00,
|
||||
price_yearly: 182.40,
|
||||
docs_per_month: 200,
|
||||
@@ -110,27 +113,27 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 2_000_000,
|
||||
providers: ["google", "deepl", "openrouter"],
|
||||
features: [
|
||||
"200 documents / mois",
|
||||
"Jusqu'à 200 pages par document",
|
||||
"Traduction IA Essentielle (DeepSeek V3.2)",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 25 Mo",
|
||||
"Glossaires personnalisés",
|
||||
"Support prioritaire",
|
||||
"Historique 90 jours",
|
||||
"pricing.plans.pro.feat1",
|
||||
"pricing.plans.pro.feat2",
|
||||
"pricing.plans.pro.feat3",
|
||||
"pricing.plans.pro.feat4",
|
||||
"pricing.plans.pro.feat5",
|
||||
"pricing.plans.pro.feat6",
|
||||
"pricing.plans.pro.feat7",
|
||||
"pricing.plans.pro.feat8",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "essential",
|
||||
api_access: false,
|
||||
priority_processing: true,
|
||||
popular: true,
|
||||
highlight: "Le plus populaire",
|
||||
description: "Pour les professionnels et équipes en croissance",
|
||||
badge: "POPULAIRE",
|
||||
highlight: "pricing.plans.pro.highlight",
|
||||
description: "pricing.plans.pro.description",
|
||||
badge: "pricing.plans.pro.badge",
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
name: "pricing.plans.business.name",
|
||||
price_monthly: 49.00,
|
||||
price_yearly: 470.40,
|
||||
docs_per_month: 1000,
|
||||
@@ -139,16 +142,16 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: 10_000_000,
|
||||
providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"],
|
||||
features: [
|
||||
"1 000 documents / mois",
|
||||
"Jusqu'à 500 pages par document",
|
||||
"IA Essentielle + Premium (Claude Haiku)",
|
||||
"Tous les fournisseurs de traduction",
|
||||
"Fichiers jusqu'à 50 Mo",
|
||||
"Accès API (10 000 appels/mois)",
|
||||
"Webhooks de notification",
|
||||
"Support dédié",
|
||||
"Historique 1 an",
|
||||
"Analytiques avancées",
|
||||
"pricing.plans.business.feat1",
|
||||
"pricing.plans.business.feat2",
|
||||
"pricing.plans.business.feat3",
|
||||
"pricing.plans.business.feat4",
|
||||
"pricing.plans.business.feat5",
|
||||
"pricing.plans.business.feat6",
|
||||
"pricing.plans.business.feat7",
|
||||
"pricing.plans.business.feat8",
|
||||
"pricing.plans.business.feat9",
|
||||
"pricing.plans.business.feat10",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "premium",
|
||||
@@ -156,11 +159,11 @@ const STATIC_PLANS: Plan[] = [
|
||||
priority_processing: true,
|
||||
team_seats: 5,
|
||||
popular: false,
|
||||
description: "Pour les équipes et organisations",
|
||||
description: "pricing.plans.business.description",
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Entreprise",
|
||||
name: "pricing.plans.enterprise.name",
|
||||
price_monthly: -1,
|
||||
price_yearly: -1,
|
||||
docs_per_month: -1,
|
||||
@@ -169,14 +172,14 @@ const STATIC_PLANS: Plan[] = [
|
||||
max_chars_per_month: -1,
|
||||
providers: ["all"],
|
||||
features: [
|
||||
"Documents illimités",
|
||||
"Tous les modèles IA (GPT-5, Claude Opus 4.6…)",
|
||||
"Déploiement on-premise ou cloud dédié",
|
||||
"SLA 99,9 % garanti",
|
||||
"Support 24/7 dédié",
|
||||
"Marque blanche (white-label)",
|
||||
"Équipes illimitées",
|
||||
"Intégrations sur mesure",
|
||||
"pricing.plans.enterprise.feat1",
|
||||
"pricing.plans.enterprise.feat2",
|
||||
"pricing.plans.enterprise.feat3",
|
||||
"pricing.plans.enterprise.feat4",
|
||||
"pricing.plans.enterprise.feat5",
|
||||
"pricing.plans.enterprise.feat6",
|
||||
"pricing.plans.enterprise.feat7",
|
||||
"pricing.plans.enterprise.feat8",
|
||||
],
|
||||
ai_translation: true,
|
||||
ai_tier: "custom",
|
||||
@@ -184,8 +187,8 @@ const STATIC_PLANS: Plan[] = [
|
||||
priority_processing: true,
|
||||
team_seats: -1,
|
||||
popular: false,
|
||||
description: "Solutions sur mesure pour grandes organisations",
|
||||
badge: "SUR DEVIS",
|
||||
description: "pricing.plans.enterprise.description",
|
||||
badge: "pricing.plans.enterprise.badge",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,7 +218,7 @@ const PLAN_COLORS: Record<string, { gradient: string; border: string; badge: str
|
||||
enterprise: { gradient: "from-amber-600 to-amber-700", border: "border-amber-700/50", badge: "bg-amber-600", button: "bg-amber-600 hover:bg-amber-500" },
|
||||
};
|
||||
|
||||
/** Évite le flash des tarifs statiques (STATIC_PLANS) avant la réponse API au refresh. */
|
||||
/** Avoids flash of static prices before the API responds on refresh. */
|
||||
function PricingDataSkeleton() {
|
||||
return (
|
||||
<>
|
||||
@@ -257,40 +260,20 @@ function PricingDataSkeleton() {
|
||||
}
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Puis-je changer de forfait à tout moment ?",
|
||||
a: "Oui. Le passage à un forfait supérieur est immédiat et proratisé. La rétrogradation prend effet à la fin de la période en cours.",
|
||||
},
|
||||
{
|
||||
q: "Qu'est-ce que la « Traduction IA Essentielle » ?",
|
||||
a: "C'est notre moteur IA basé sur DeepSeek V3.2 via OpenRouter. Il comprend le contexte de vos documents, préserve la mise en page et gère les termes techniques bien mieux qu'une traduction classique.",
|
||||
},
|
||||
{
|
||||
q: "Quelle est la différence entre IA Essentielle et IA Premium ?",
|
||||
a: "L'IA Essentielle utilise DeepSeek V3.2 (excellent rapport qualité/prix). L'IA Premium utilise Claude 3.5 Haiku d'Anthropic, plus précis sur les documents juridiques, médicaux et techniques complexes.",
|
||||
},
|
||||
{
|
||||
q: "Mes documents sont-ils conservés après traduction ?",
|
||||
a: "Les fichiers traduits sont disponibles selon votre forfait (30 jours Starter, 90 jours Pro, 1 an Business). Ils sont chiffrés au repos et en transit.",
|
||||
},
|
||||
{
|
||||
q: "Que se passe-t-il si je dépasse mon quota mensuel ?",
|
||||
a: "Vous pouvez acheter des crédits supplémentaires à l'unité, ou upgrader votre forfait. Vous êtes notifié à 80 % d'utilisation.",
|
||||
},
|
||||
{
|
||||
q: "Y a-t-il une version d'essai gratuite des forfaits payants ?",
|
||||
a: "Le forfait Gratuit est permanent et sans carte bancaire. Pour les forfaits Pro et Business, contactez-nous pour un accès d'essai de 14 jours.",
|
||||
},
|
||||
{
|
||||
q: "Quels formats de fichiers sont supportés ?",
|
||||
a: "Word (.docx), Excel (.xlsx/.xls), PowerPoint (.pptx), et bientôt PDF. Tous les plans supportent les mêmes formats.",
|
||||
},
|
||||
{ q: "pricing.faq.q1", a: "pricing.faq.a1" },
|
||||
{ q: "pricing.faq.q2", a: "pricing.faq.a2" },
|
||||
{ q: "pricing.faq.q3", a: "pricing.faq.a3" },
|
||||
{ q: "pricing.faq.q4", a: "pricing.faq.a4" },
|
||||
{ q: "pricing.faq.q5", a: "pricing.faq.a5" },
|
||||
{ q: "pricing.faq.q6", a: "pricing.faq.a6" },
|
||||
{ q: "pricing.faq.q7", a: "pricing.faq.a7" },
|
||||
];
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
Main component
|
||||
───────────────────────────────────────────── */
|
||||
export default function PricingPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const planFromUrl = searchParams.get("plan");
|
||||
@@ -303,11 +286,11 @@ export default function PricingPage() {
|
||||
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
|
||||
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
|
||||
/** Tant que false : on n’affiche pas STATIC_PLANS (évite le flash de faux prix au refresh). */
|
||||
/** Until false: don't show STATIC_PLANS (avoids flash of stale prices on refresh). */
|
||||
const [pricingLoaded, setPricingLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch live plans — backend returns prices set by admin (pas de cache navigateur)
|
||||
// Fetch live plans — backend returns prices set by admin (no browser cache)
|
||||
const plansUrl = `${API_BASE}/api/v1/auth/plans`;
|
||||
fetch(plansUrl, { cache: "no-store" })
|
||||
.then(async (r) => {
|
||||
@@ -327,7 +310,7 @@ export default function PricingPage() {
|
||||
})
|
||||
.catch((err) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("[pricing] Impossible de charger /api/v1/auth/plans — affichage des tarifs par défaut.", err);
|
||||
console.warn("[pricing] Cannot load /api/v1/auth/plans — showing default prices.", err);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -354,7 +337,7 @@ export default function PricingPage() {
|
||||
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
|
||||
const targetPlan = plans.find(p => p.id === planFromUrl);
|
||||
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
|
||||
// Directly initiate checkout — no more redirect to /settings/subscription
|
||||
// Directly initiate checkout
|
||||
handleSubscribe(planFromUrl);
|
||||
}
|
||||
}
|
||||
@@ -399,7 +382,7 @@ export default function PricingPage() {
|
||||
const backendMessage = data.message ?? data.error ?? data.data?.error;
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(backendMessage || "Erreur lors de la création du paiement.");
|
||||
throw new Error(backendMessage || t('pricing.toast.paymentError'));
|
||||
}
|
||||
|
||||
if (url) {
|
||||
@@ -408,11 +391,11 @@ export default function PricingPage() {
|
||||
// Demo mode: Stripe not yet configured
|
||||
setToastMsg({
|
||||
type: 'ok',
|
||||
text: `Mode démo — Stripe n'est pas encore configuré. En production, vous seriez redirigé vers le paiement pour activer le forfait ${planId}.`,
|
||||
text: t('pricing.toast.demo', { planId }),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Erreur réseau. Veuillez réessayer.";
|
||||
const message = error instanceof Error ? error.message : t('pricing.toast.networkError');
|
||||
setToastMsg({ type: 'err', text: message });
|
||||
} finally {
|
||||
setLoadingPlanId(null);
|
||||
@@ -429,21 +412,21 @@ export default function PricingPage() {
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Retour
|
||||
{t('pricing.nav.back')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={isLoggedIn ? "/dashboard" : "/"}
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors"
|
||||
>
|
||||
{isLoggedIn ? "Dashboard" : "Accueil"}
|
||||
{isLoggedIn ? "Dashboard" : t('pricing.nav.home')}
|
||||
</Link>
|
||||
{isLoggedIn && (
|
||||
<Link
|
||||
href="/settings/subscription"
|
||||
href="/dashboard/profile"
|
||||
className="px-3 py-1.5 rounded-lg text-sm bg-accent/80 hover:bg-accent text-accent-foreground transition-colors"
|
||||
>
|
||||
Mon abonnement
|
||||
{t('pricing.nav.mySubscription')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,14 +451,13 @@ export default function PricingPage() {
|
||||
<div className="max-w-7xl mx-auto px-4 pt-20 pb-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-accent/10 border border-accent/20 text-accent text-sm font-medium mb-6">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Modèles IA mis à jour — Mars 2026
|
||||
{t('pricing.header.badge')}
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tight mb-4 bg-gradient-to-r from-foreground via-accent/80 to-accent bg-clip-text text-transparent">
|
||||
Un forfait pour chaque besoin
|
||||
{t('pricing.header.title')}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
|
||||
Traduisez vos documents Word, Excel et PowerPoint en conservant
|
||||
la mise en page originale. Sans jamais saisir de clé API.
|
||||
{t('pricing.header.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Monthly / Yearly toggle */}
|
||||
@@ -487,7 +469,7 @@ export default function PricingPage() {
|
||||
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Mensuel
|
||||
{t('pricing.billing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
@@ -496,7 +478,7 @@ export default function PricingPage() {
|
||||
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Annuel
|
||||
{t('pricing.billing.yearly')}
|
||||
<span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
−{annualDiscountPercent} %
|
||||
</span>
|
||||
@@ -504,7 +486,7 @@ export default function PricingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Plan cards (+ comparaison + crédits) : squelette jusqu’à l’API pour éviter le flash des tarifs statiques */}
|
||||
{/* ── Plan cards (+ comparison + credits): skeleton until API responds to avoid stale price flash ── */}
|
||||
<div className="max-w-7xl mx-auto px-4 pb-20">
|
||||
{!pricingLoaded ? (
|
||||
<PricingDataSkeleton />
|
||||
@@ -534,13 +516,13 @@ export default function PricingPage() {
|
||||
"absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold text-white",
|
||||
colors.badge
|
||||
)}>
|
||||
{plan.badge}
|
||||
{t(plan.badge)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-3 right-4 px-3 py-1 rounded-full text-xs font-bold bg-emerald-600 text-white flex items-center gap-1">
|
||||
<BadgeCheck className="w-3 h-3" /> Mon forfait
|
||||
<BadgeCheck className="w-3 h-3" /> {t('pricing.card.myPlan')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -550,27 +532,27 @@ export default function PricingPage() {
|
||||
<div className="p-1.5 bg-white/10 rounded-lg">
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white">{plan.name}</span>
|
||||
<span className="font-bold text-white">{t(plan.name)}</span>
|
||||
</div>
|
||||
|
||||
{isEnterprise ? (
|
||||
<div className="text-3xl font-bold text-white">Sur devis</div>
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.onRequest')}</div>
|
||||
) : price === 0 ? (
|
||||
<div className="text-3xl font-bold text-white">Gratuit</div>
|
||||
<div className="text-3xl font-bold text-white">{t('pricing.card.free')}</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-1">
|
||||
<span className="text-3xl font-bold text-white">{price} €</span>
|
||||
<span className="text-white/70 text-sm pb-1">/mois</span>
|
||||
<span className="text-white/70 text-sm pb-1">{t('pricing.card.perMonth')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isYearly && plan.price_yearly > 0 && (
|
||||
<div className="text-white/70 text-xs mt-1">
|
||||
Facturé {plan.price_yearly.toFixed(2)} € / an
|
||||
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-white/60 text-xs mt-2">{plan.description}</p>
|
||||
<p className="text-white/60 text-xs mt-2">{t(plan.description || '')}</p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
@@ -579,21 +561,21 @@ export default function PricingPage() {
|
||||
<div className="grid grid-cols-1 gap-2 mb-3">
|
||||
<Stat
|
||||
icon={<FileText className="w-3.5 h-3.5" />}
|
||||
label="Documents"
|
||||
value={plan.docs_per_month === -1 ? "Illimité" : `${plan.docs_per_month} / mois`}
|
||||
label={t('pricing.card.documents')}
|
||||
value={plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Layers className="w-3.5 h-3.5" />}
|
||||
label="Pages max"
|
||||
value={plan.max_pages_per_doc === -1 ? "Illimité" : `${plan.max_pages_per_doc} p / doc`}
|
||||
label={t('pricing.card.pagesMax')}
|
||||
value={plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
|
||||
/>
|
||||
{plan.ai_translation && (
|
||||
<Stat
|
||||
icon={<Brain className="w-3.5 h-3.5 text-violet-400" />}
|
||||
label="Traduction IA"
|
||||
label={t('pricing.card.aiTranslation')}
|
||||
value={
|
||||
plan.ai_tier === "essential" ? "Essentielle" :
|
||||
plan.ai_tier === "premium" ? "Essentielle + Premium" : "Sur mesure"
|
||||
plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
|
||||
plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') : t('pricing.card.aiCustom')
|
||||
}
|
||||
highlight
|
||||
/>
|
||||
@@ -604,7 +586,7 @@ export default function PricingPage() {
|
||||
{plan.features.map((feat, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<Check className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-muted-foreground text-xs leading-snug">{feat}</span>
|
||||
<span className="text-muted-foreground text-xs leading-snug">{t(feat)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -616,9 +598,9 @@ export default function PricingPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-success/50 text-success hover:bg-success/10"
|
||||
onClick={() => router.push("/settings/subscription")}
|
||||
onClick={() => router.push("/dashboard/profile")}
|
||||
>
|
||||
<BadgeCheck className="w-4 h-4 mr-1" /> Gérer mon forfait
|
||||
<BadgeCheck className="w-4 h-4 me-1" /> {t('pricing.card.managePlan')}
|
||||
</Button>
|
||||
) : plan.id === "free" && !currentPlan ? (
|
||||
<Button
|
||||
@@ -626,7 +608,7 @@ export default function PricingPage() {
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted/30"
|
||||
onClick={() => router.push("/auth/register")}
|
||||
>
|
||||
Commencer gratuitement
|
||||
{t('pricing.card.startFree')}
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
@@ -644,11 +626,11 @@ export default function PricingPage() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Traitement…
|
||||
{t('pricing.card.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEnterprise ? "Nous contacter" : "Choisir ce forfait"}
|
||||
{isEnterprise ? t('pricing.card.contactUs') : t('pricing.card.choosePlan')}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
@@ -662,33 +644,33 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── Feature comparison table ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Comparaison détaillée</h2>
|
||||
<p className="text-muted-foreground text-center mb-10">Tout ce qui est inclus dans chaque forfait</p>
|
||||
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-10">{t('pricing.comparison.subtitle')}</p>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-border/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/60 border-b border-border/40">
|
||||
<th className="text-left py-4 px-6 text-muted-foreground font-medium">Fonctionnalité</th>
|
||||
<th className="text-start py-4 px-6 text-muted-foreground font-medium">{t('pricing.comparison.feature')}</th>
|
||||
{plans.slice(0, 4).map((p) => (
|
||||
<th key={p.id} className={cn("py-4 px-4 text-center font-medium", p.popular ? "text-accent" : "text-muted-foreground")}>
|
||||
{p.name}
|
||||
{t(p.name)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: "Documents / mois", vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
||||
{ label: "Pages max / document", vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
||||
{ label: "Taille max fichier", vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} Mo`) },
|
||||
{ label: "Google Traduction", vals: plans.slice(0,4).map(() => true) },
|
||||
{ label: "DeepL", vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
||||
{ label: "Traduction IA Essentielle", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: "Traduction IA Premium", vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: "Accès API", vals: plans.slice(0,4).map(p => p.api_access) },
|
||||
{ label: "Traitement prioritaire", vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||||
{ label: "Support", vals: ["Communauté", "E-mail", "Prioritaire", "Dédié"] },
|
||||
{ label: t('pricing.comparison.docsPerMonth'), vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) },
|
||||
{ label: t('pricing.comparison.pagesMaxPerDoc'), vals: plans.slice(0,4).map(p => p.max_pages_per_doc === -1 ? "∞" : String(p.max_pages_per_doc)) },
|
||||
{ label: t('pricing.comparison.maxFileSize'), vals: plans.slice(0,4).map(p => p.max_file_size_mb === -1 ? "∞" : `${p.max_file_size_mb} ${t('pricing.comparison.mb')}`) },
|
||||
{ label: t('pricing.comparison.googleTranslation'), vals: plans.slice(0,4).map(() => true) },
|
||||
{ label: t('pricing.comparison.deepl'), vals: plans.slice(0,4).map(p => p.providers.includes("deepl") || p.providers.includes("all")) },
|
||||
{ label: t('pricing.comparison.aiEssential'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "essential" || p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: t('pricing.comparison.aiPremium'), vals: plans.slice(0,4).map(p => p.ai_translation && (p.ai_tier === "premium" || p.ai_tier === "custom")) },
|
||||
{ label: t('pricing.comparison.apiAccess'), vals: plans.slice(0,4).map(p => p.api_access) },
|
||||
{ label: t('pricing.comparison.priorityProcessing'), vals: plans.slice(0,4).map(p => p.priority_processing) },
|
||||
{ label: t('pricing.comparison.support'), vals: [t('pricing.comparison.support.community'), t('pricing.comparison.support.email'), t('pricing.comparison.support.priority'), t('pricing.comparison.support.dedicated')] },
|
||||
].map((row, i) => (
|
||||
<tr key={i} className={cn("border-b border-border/20", i % 2 === 0 ? "bg-muted/20" : "")}>
|
||||
<td className="py-3 px-6 text-muted-foreground">{row.label}</td>
|
||||
@@ -714,10 +696,10 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── Credits ── */}
|
||||
<div className="mt-20">
|
||||
<h2 className="text-3xl font-bold text-center mb-2">Crédits supplémentaires</h2>
|
||||
<h2 className="text-3xl font-bold text-center mb-2">{t('pricing.credits.title')}</h2>
|
||||
<p className="text-muted-foreground text-center mb-8">
|
||||
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement.
|
||||
<span className="text-muted-foreground/70"> 1 crédit = 1 page traduite.</span>
|
||||
{t('pricing.credits.subtitle')}
|
||||
<span className="text-muted-foreground/70"> {t('pricing.credits.perPage')}</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
|
||||
{credits.map((pkg, i) => (
|
||||
@@ -732,15 +714,15 @@ export default function PricingPage() {
|
||||
>
|
||||
{pkg.popular && (
|
||||
<div className="absolute -top-2.5 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-accent text-accent-foreground text-xs rounded-full font-bold">
|
||||
Le meilleur rapport
|
||||
{t('pricing.credits.bestValue')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-foreground">{pkg.credits}</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">crédits</div>
|
||||
<div className="text-muted-foreground text-xs mb-3">{t('pricing.credits.unit')}</div>
|
||||
<div className="text-xl font-bold text-foreground">{pkg.price} €</div>
|
||||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} cts / crédit</div>
|
||||
<div className="text-muted-foreground text-xs">{(pkg.price_per_credit * 100).toFixed(0)} {t('pricing.credits.centsPerCredit')}</div>
|
||||
<button className="mt-3 w-full py-1.5 rounded-lg bg-muted hover:bg-muted/80 text-foreground text-xs transition-all">
|
||||
Acheter
|
||||
{t('pricing.credits.buy')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -752,15 +734,15 @@ export default function PricingPage() {
|
||||
{/* ── Trust signals ── */}
|
||||
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ icon: <Shield className="w-6 h-6 text-emerald-500" />, title: "Chiffrement de bout en bout", sub: "TLS 1.3 + AES-256 au repos" },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" },
|
||||
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: "Disponible 24/7", sub: "Uptime garanti 99,9 %" },
|
||||
].map((t, i) => (
|
||||
{ icon: <Shield className="w-6 h-6 text-emerald-500" />, title: t('pricing.trust.encryption.title'), sub: t('pricing.trust.encryption.sub') },
|
||||
{ icon: <Globe className="w-6 h-6 text-blue-500" />, title: t('pricing.trust.languages.title'), sub: t('pricing.trust.languages.sub') },
|
||||
{ icon: <Gauge className="w-6 h-6 text-accent" />, title: t('pricing.trust.parallel.title'), sub: t('pricing.trust.parallel.sub') },
|
||||
{ icon: <Clock className="w-6 h-6 text-amber-500" />, title: t('pricing.trust.availability.title'), sub: t('pricing.trust.availability.sub') },
|
||||
].map((signal, i) => (
|
||||
<div key={i} className="flex flex-col items-center text-center p-6 rounded-2xl bg-card border border-border/30">
|
||||
<div className="p-3 rounded-full bg-muted/50 mb-3">{t.icon}</div>
|
||||
<div className="font-semibold text-foreground text-sm mb-1">{t.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{t.sub}</div>
|
||||
<div className="p-3 rounded-full bg-muted/50 mb-3">{signal.icon}</div>
|
||||
<div className="font-semibold text-foreground text-sm mb-1">{signal.title}</div>
|
||||
<div className="text-muted-foreground text-xs">{signal.sub}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -769,39 +751,37 @@ export default function PricingPage() {
|
||||
<div className="mt-20 p-8 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Brain className="w-6 h-6 text-accent" />
|
||||
<h2 className="text-2xl font-bold">Nos modèles IA — Mars 2026</h2>
|
||||
<h2 className="text-2xl font-bold">{t('pricing.aiModels.title')}</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="p-5 rounded-xl bg-card border border-border/40">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-semibold">Traduction IA Essentielle</span>
|
||||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">Forfait Pro</Badge>
|
||||
<span className="font-semibold">{t('pricing.aiModels.essential.title')}</span>
|
||||
<Badge className="ml-auto text-xs bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-300">{t('pricing.aiModels.essential.plan')}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
Basée sur <strong className="text-foreground">DeepSeek V3.2</strong> — le modèle IA le plus rentable de 2026.
|
||||
Qualité comparable aux modèles frontier à 1/50ème du coût.
|
||||
{t('pricing.aiModels.essential.descPrefix')} <strong className="text-foreground">DeepSeek V3.2</strong> {t('pricing.aiModels.essential.descSuffix')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">163K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.essential.context')}</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$0.38 per 1M</span>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded">Excellent rapport qualité/prix</span>
|
||||
<span className="px-2 py-1 bg-success/10 text-success rounded">{t('pricing.aiModels.essential.value')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-xl bg-card border border-accent/30">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Crown className="w-4 h-4 text-accent" />
|
||||
<span className="font-semibold">Traduction IA Premium</span>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge>
|
||||
<span className="font-semibold">{t('pricing.aiModels.premium.title')}</span>
|
||||
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
Basée sur <strong className="text-foreground">Claude 3.5 Haiku</strong> d'Anthropic — précis sur les documents
|
||||
juridiques, médicaux et techniques complexes.
|
||||
{t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude 3.5 Haiku</strong> {t('pricing.aiModels.premium.descSuffix')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-muted rounded">200K tokens de contexte</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">{t('pricing.aiModels.premium.context')}</span>
|
||||
<span className="px-2 py-1 bg-muted rounded">$0.25/$1.25 per 1M</span>
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">Meilleure précision</span>
|
||||
<span className="px-2 py-1 bg-accent/10 text-accent rounded">{t('pricing.aiModels.premium.precision')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -809,7 +789,7 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── FAQ ── */}
|
||||
<div className="mt-20 max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-center mb-10">Questions fréquentes</h2>
|
||||
<h2 className="text-3xl font-bold text-center mb-10">{t('pricing.faq.title')}</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQS.map((faq, i) => (
|
||||
<div
|
||||
@@ -817,10 +797,10 @@ export default function PricingPage() {
|
||||
className="rounded-xl border border-border/40 bg-card overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-5 text-left hover:bg-muted/20 transition-colors"
|
||||
className="w-full flex items-center justify-between p-5 text-start hover:bg-muted/20 transition-colors"
|
||||
onClick={() => setOpenFAQ(openFAQ === i ? null : i)}
|
||||
>
|
||||
<span className="font-medium text-foreground">{faq.q}</span>
|
||||
<span className="font-medium text-foreground">{t(faq.q)}</span>
|
||||
{openFAQ === i
|
||||
? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
: <ChevronDown className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
@@ -828,7 +808,7 @@ export default function PricingPage() {
|
||||
</button>
|
||||
{openFAQ === i && (
|
||||
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4">
|
||||
{faq.a}
|
||||
{t(faq.a)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -838,20 +818,20 @@ export default function PricingPage() {
|
||||
|
||||
{/* ── CTA bottom ── */}
|
||||
<div className="mt-20 text-center p-12 rounded-2xl bg-gradient-to-br from-accent/10 to-card border border-accent/20">
|
||||
<h2 className="text-3xl font-bold mb-3">Prêt à commencer ?</h2>
|
||||
<h2 className="text-3xl font-bold mb-3">{t('pricing.cta.title')}</h2>
|
||||
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
|
||||
Commencez gratuitement, sans carte bancaire. Passez à un forfait supérieur quand vous en avez besoin.
|
||||
{t('pricing.cta.subtitle')}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/auth/register">
|
||||
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-3 text-base font-semibold">
|
||||
Créer un compte gratuit
|
||||
<ArrowRight className="ml-2 w-5 h-5" />
|
||||
{t('pricing.cta.createAccount')}
|
||||
<ArrowRight className="ms-2 w-5 h-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/auth/login">
|
||||
<Button variant="outline" className="px-8 py-3 text-base">
|
||||
Se connecter
|
||||
{t('pricing.cta.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -567,12 +567,12 @@ export function FileUploader() {
|
||||
>
|
||||
{isTranslating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<Loader2 className="me-2 h-5 w-5 animate-spin" />
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Translate Document
|
||||
</>
|
||||
)}
|
||||
@@ -630,7 +630,7 @@ export function FileUploader() {
|
||||
size="lg"
|
||||
className="group px-8"
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Download Translated Document
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import {
|
||||
Globe2,
|
||||
FileText,
|
||||
Zap,
|
||||
Shield,
|
||||
Brain,
|
||||
Server
|
||||
} from "lucide-react"
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Globe2,
|
||||
title: "100+ Languages",
|
||||
description: "Translate between any language pair with high accuracy",
|
||||
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: Brain,
|
||||
title: "AI-Powered",
|
||||
description: "Advanced neural translation for natural, context-aware results",
|
||||
color: "text-teal-400",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Enterprise Ready",
|
||||
description: "API access, team management, and dedicated support",
|
||||
color: "text-orange-400",
|
||||
},
|
||||
]
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section className="py-16 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-3">
|
||||
Everything You Need for Document Translation
|
||||
</h2>
|
||||
<p className="text-muted-foreground 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="flex flex-col items-center text-center p-6 rounded-xl border border-border bg-card/50 hover:bg-card transition-colors"
|
||||
>
|
||||
<div className={`mb-4 ${feature.color}`}>
|
||||
<Icon className="size-8" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground mb-2">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Link from "next/link"
|
||||
import { FileSpreadsheet, FileText, Presentation } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="flex flex-col items-center gap-6 px-6 pt-16 pb-8 text-center md:pt-24 md:pb-12">
|
||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-4 py-1.5 text-xs font-medium text-muted-foreground shadow-sm">
|
||||
<span className="inline-block size-1.5 rounded-full bg-green-500" />
|
||||
Now with Pro LLM Engine
|
||||
</div>
|
||||
|
||||
<h1 className="max-w-2xl text-balance text-4xl font-bold leading-tight tracking-tight text-foreground md:text-5xl lg:text-6xl">
|
||||
Translate Office Documents. Keep the Format Perfect.
|
||||
</h1>
|
||||
|
||||
<p className="max-w-xl text-pretty text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||
Upload your Excel, Word, or PowerPoint files and get accurate translations with zero formatting loss.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Button asChild size="lg">
|
||||
<Link href="/auth/register">Try Free</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild size="lg">
|
||||
<Link href="/pricing">View Pricing</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileSpreadsheet className="size-4 text-green-500" />
|
||||
<span>.xlsx</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="size-4 text-blue-500" />
|
||||
<span>.docx</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Presentation className="size-4 text-orange-500" />
|
||||
<span>.pptx</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function HeroWordComparison() {
|
||||
</div>
|
||||
|
||||
<div className="relative p-2 sm:p-4 bg-[#1e1e1e]">
|
||||
<p className="mb-3 text-center text-xs text-slate-400 sm:text-left sm:px-1">
|
||||
<p className="mb-3 text-center text-xs text-slate-400 sm:text-start sm:px-1">
|
||||
{t("landing.heroDoc.caption")}
|
||||
</p>
|
||||
<div className="flex rounded-lg overflow-hidden border border-[#3c3c3c] shadow-inner min-h-[240px] sm:min-h-[300px]">
|
||||
@@ -86,7 +86,7 @@ export function HeroWordComparison() {
|
||||
{t("landing.heroDoc.badgeOriginal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-start">
|
||||
<article
|
||||
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
|
||||
style={{
|
||||
@@ -108,7 +108,7 @@ export function HeroWordComparison() {
|
||||
{t("landing.heroDoc.badgeTranslated")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-left">
|
||||
<div className="flex-1 p-3 sm:p-4 overflow-hidden bg-white text-start">
|
||||
<article
|
||||
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
|
||||
style={{
|
||||
|
||||
@@ -1,226 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { HeroWordComparison } from "@/components/landing/hero-word-comparison";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import {
|
||||
Table2,
|
||||
FileText,
|
||||
Table2,
|
||||
Presentation,
|
||||
Bot,
|
||||
Lock,
|
||||
Zap,
|
||||
FileSpreadsheet,
|
||||
Check,
|
||||
PlayCircle,
|
||||
ShieldCheck,
|
||||
Clock,
|
||||
Languages,
|
||||
Zap,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Eye,
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
Brain,
|
||||
BookOpen,
|
||||
Upload,
|
||||
ArrowLeftRight,
|
||||
} from "lucide-react";
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useTranslation();
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const [prices, setPrices] = useState({
|
||||
starter: { monthly: 9, yearly: 86.40 },
|
||||
pro: { monthly: 19, yearly: 182.40 },
|
||||
business: { monthly: 49, yearly: 470.40 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/auth/plans`, { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((json) => {
|
||||
if (!json) return;
|
||||
const d = json.data ?? json;
|
||||
const plans = Array.isArray(d.plans) ? d.plans : [];
|
||||
const map: Record<string, { monthly: number; yearly: number }> = {};
|
||||
for (const p of plans) {
|
||||
if (p.id && typeof p.price_monthly === "number") {
|
||||
map[p.id] = {
|
||||
monthly: p.price_monthly,
|
||||
yearly: typeof p.price_yearly === "number" ? p.price_yearly : p.price_monthly * 12,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (map.starter || map.pro || map.business) {
|
||||
setPrices((prev) => ({
|
||||
starter: map.starter || prev.starter,
|
||||
pro: map.pro || prev.pro,
|
||||
business: map.business || prev.business,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground font-sans">
|
||||
{/* Header */}
|
||||
{/* ── Header ── */}
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center rounded-lg bg-primary/10 p-1.5">
|
||||
<Languages className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<span className="text-foreground text-lg font-bold tracking-tight">
|
||||
Office Translator
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#features"
|
||||
>
|
||||
{t("nav.features")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#why">
|
||||
{t("landing.nav.whyUs")}
|
||||
</a>
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#pricing"
|
||||
>
|
||||
{t("nav.pricing")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#formats">
|
||||
{t("landing.nav.formats")}
|
||||
</a>
|
||||
<a
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
|
||||
href="#enterprise"
|
||||
>
|
||||
{t("nav.enterprise")}
|
||||
<a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#pricing">
|
||||
{t("landing.nav.pricing")}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher variant="button" />
|
||||
<LanguageSwitcher variant="select" />
|
||||
<ThemeToggle />
|
||||
<a
|
||||
className="hidden sm:inline-flex h-9 items-center justify-center rounded-lg px-4 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/auth/login"
|
||||
>
|
||||
{t("common.login")}
|
||||
{t("landing.nav.login")}
|
||||
</a>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
>
|
||||
<Link href="/auth/register">{t("common.signup")}</Link>
|
||||
<Button asChild className="bg-primary text-primary-foreground font-bold hover:bg-primary/90">
|
||||
<Link href="/auth/register">{t("landing.nav.startFree")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-20 pb-32 overflow-hidden">
|
||||
{/* ── Hero ── */}
|
||||
<section className="relative pt-20 pb-28 overflow-hidden">
|
||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background" />
|
||||
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
|
||||
{/* Hero Text */}
|
||||
<div className="flex-1 text-center lg:text-left space-y-8">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl xl:text-7xl">
|
||||
{t("hero.title")}
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
|
||||
{t("hero.titleHighlight")}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/5 px-4 py-1.5 text-sm font-medium text-primary mb-8">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("landing.hero.badge")}
|
||||
</div>
|
||||
|
||||
<p className="mx-auto lg:mx-0 max-w-2xl text-lg text-muted-foreground">
|
||||
{t("hero.subtitle")}
|
||||
</p>
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl xl:text-7xl mx-auto max-w-5xl">
|
||||
{t("landing.hero.title1")}
|
||||
<br />
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
|
||||
{t("landing.hero.title2")}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
>
|
||||
<Link href="/auth/register">{t("hero.cta")}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
>
|
||||
<PlayCircle className="h-5 w-5" />
|
||||
{t("hero.demoCta")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-8 mx-auto max-w-2xl text-lg text-muted-foreground">
|
||||
{t("landing.hero.subtitle")}
|
||||
</p>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<div className="pt-8 flex flex-wrap justify-center lg:justify-start gap-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<span>{t("hero.badge1")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-5 w-5 text-primary" />
|
||||
<span>{t("hero.badge2")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90 text-base px-8"
|
||||
>
|
||||
<Link href="/auth/register">
|
||||
{t("landing.hero.cta")}
|
||||
<ArrowRight className="ms-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="lg" className="text-base px-8" asChild>
|
||||
<Link href="#pricing">{t("landing.hero.seePlans")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<div className="mt-12 flex flex-wrap justify-center gap-8 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.filesDeleted")}</span>
|
||||
</div>
|
||||
|
||||
{/* Hero Visual — Word EN | FR + illustrations */}
|
||||
<div className="flex-1 w-full max-w-lg lg:max-w-none relative">
|
||||
<HeroWordComparison />
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheck className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.noBait")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5 text-primary" />
|
||||
<span>{t("landing.trust.preview")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format pills */}
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-3">
|
||||
{[".DOCX", ".XLSX", ".PPTX", ".PDF"].map((fmt) => (
|
||||
<span
|
||||
key={fmt}
|
||||
className="inline-flex items-center rounded-full border border-border bg-card px-4 py-2 text-sm font-mono font-semibold text-foreground"
|
||||
>
|
||||
{fmt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="py-24 bg-background relative" id="features">
|
||||
{/* ── How it works ── */}
|
||||
<section className="py-24 bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4">
|
||||
{t("features.title")}
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.howItWorks.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("features.subtitle")}
|
||||
{t("landing.howItWorks.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
{/* Feature 1 - Excel */}
|
||||
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Table2 className="h-6 w-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{
|
||||
step: "1",
|
||||
icon: Upload,
|
||||
title: t("landing.howItWorks.step1.title"),
|
||||
desc: t("landing.howItWorks.step1.desc"),
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
step: "2",
|
||||
icon: ArrowLeftRight,
|
||||
title: t("landing.howItWorks.step2.title"),
|
||||
desc: t("landing.howItWorks.step2.desc"),
|
||||
color: "text-primary",
|
||||
},
|
||||
{
|
||||
step: "3",
|
||||
icon: Check,
|
||||
title: t("landing.howItWorks.step3.title"),
|
||||
desc: t("landing.howItWorks.step3.desc"),
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex flex-col items-center text-center">
|
||||
<div className={`flex size-16 items-center justify-center rounded-2xl bg-muted/50 mb-4 ${s.color}`}>
|
||||
<s.icon className="size-7" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold mb-2">{s.title}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">{s.desc}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.excel.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.excel.description")}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature 2 - Word */}
|
||||
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.word.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.word.description")}</p>
|
||||
{/* ── AI Translation Engine ── */}
|
||||
<section className="py-24 bg-muted/30" id="ai-engine">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary/30 bg-primary/5 px-4 py-1.5 text-sm font-medium text-primary mb-6">
|
||||
<Brain className="h-4 w-4" />
|
||||
{t("landing.ai.badge")}
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.ai.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.ai.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 3 - PowerPoint */}
|
||||
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Presentation className="h-6 w-6" />
|
||||
{/* Three pillars */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-16">
|
||||
{[
|
||||
{ icon: Zap, title: t("landing.ai.context.title"), desc: t("landing.ai.context.desc"), color: "text-blue-500" },
|
||||
{ icon: BookOpen, title: t("landing.ai.glossary.title"), desc: t("landing.ai.glossary.desc"), color: "text-primary" },
|
||||
{ icon: Eye, title: t("landing.ai.vision.title"), desc: t("landing.ai.vision.desc"), color: "text-emerald-500" },
|
||||
].map((feature, i) => (
|
||||
<div key={i} className="group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-muted/50 ${feature.color}`}>
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
|
||||
<p className="text-muted-foreground text-sm">{feature.desc}</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.powerpoint.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t("features.powerpoint.description")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature 4 - AI */}
|
||||
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Bot className="h-6 w-6" />
|
||||
{/* Comparison */}
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-border bg-card p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("landing.ai.comparison.source")}</p>
|
||||
<p className="text-sm text-foreground font-medium italic">“{t("landing.ai.comparison.sourceText")}”</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.ai.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.ai.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* Feature 5 - Speed */}
|
||||
<div className="w-full md:w-[calc(50%-12px)] lg:w-[calc(33.333%-16px)] group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors">
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Zap className="h-6 w-6" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("landing.ai.comparison.google")}</p>
|
||||
<p className="text-sm text-destructive/80">“{t("landing.ai.comparison.googleText")}”</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-primary">{t("landing.ai.comparison.ai")}</p>
|
||||
<p className="text-sm text-emerald-600 font-medium">“{t("landing.ai.comparison.aiText")}”</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-foreground mb-2">
|
||||
{t("features.speed.title")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">{t("features.speed.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<section className="py-24 bg-muted/30" id="pricing">
|
||||
{/* ── Why Us — Format Preservation ── */}
|
||||
<section className="py-24 bg-muted/30" id="why">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.why.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.why.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: LayoutGrid,
|
||||
title: t("landing.why.smartart.title"),
|
||||
desc: t("landing.why.smartart.desc"),
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: t("landing.why.toc.title"),
|
||||
desc: t("landing.why.toc.desc"),
|
||||
},
|
||||
{
|
||||
icon: Table2,
|
||||
title: t("landing.why.charts.title"),
|
||||
desc: t("landing.why.charts.desc"),
|
||||
},
|
||||
{
|
||||
icon: Presentation,
|
||||
title: t("landing.why.shapes.title"),
|
||||
desc: t("landing.why.shapes.desc"),
|
||||
},
|
||||
{
|
||||
icon: FileSpreadsheet,
|
||||
title: t("landing.why.headers.title"),
|
||||
desc: t("landing.why.headers.desc"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t("landing.why.languages.title"),
|
||||
desc: t("landing.why.languages.desc"),
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group relative rounded-2xl border border-border bg-card p-8 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<feature.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-2">{feature.title}</h3>
|
||||
<p className="text-muted-foreground">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Honest Pricing Comparison ── */}
|
||||
<section className="py-24 bg-background" id="pricing">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4">
|
||||
{t("pricing.title")}
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.pricing.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground">{t("pricing.subtitle")}</p>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.pricing.subtitle")}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="inline-flex items-center gap-3 bg-card border border-border rounded-full p-1.5">
|
||||
@@ -228,21 +357,24 @@ export function LandingPage() {
|
||||
onClick={() => setIsYearly(false)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all",
|
||||
!isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
|
||||
!isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t("common.monthly")}
|
||||
{t("landing.pricing.monthly")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsYearly(true)}
|
||||
className={cn(
|
||||
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2",
|
||||
isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
|
||||
isYearly ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t("common.yearly")}
|
||||
<span className={cn("text-xs px-1.5 py-0.5 rounded-full font-bold", isYearly ? "bg-background text-primary" : "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400")}>
|
||||
{t("common.save20")}
|
||||
{t("landing.pricing.yearly")}
|
||||
<span className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded-full font-bold",
|
||||
isYearly ? "bg-background text-primary" : "bg-emerald-500/20 text-emerald-600"
|
||||
)}>
|
||||
-20%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -250,140 +382,218 @@ export function LandingPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto">
|
||||
{/* Starter Plan */}
|
||||
{/* Starter */}
|
||||
<div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{t("pricing.starter.name")}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
{t("pricing.starter.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.starter.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.starter.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.starter.priceYearly") : t("pricing.starter.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.starter.yearly / 12 : prices.starter.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
|
||||
</div>
|
||||
<div className="h-6 mb-4">
|
||||
{isYearly && (
|
||||
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("pricing.starter.billedYearly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<li key={i} className="flex items-center text-muted-foreground text-sm">
|
||||
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
|
||||
{t(`pricing.starter.features.${i}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.starter.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.starter.f1"),
|
||||
t("landing.pricing.starter.f2"),
|
||||
t("landing.pricing.starter.f3"),
|
||||
t("landing.pricing.starter.f4"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pricing?plan=starter">{t("common.signup")}</Link>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/pricing?plan=starter">{t("landing.pricing.starter.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Pro Plan */}
|
||||
<div className="relative rounded-2xl border border-primary bg-card p-8 flex flex-col shadow-lg shadow-primary/10 scale-105 z-10">
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-warning px-3 py-1 text-xs font-bold text-warning-foreground uppercase tracking-wide">
|
||||
{t("common.popular")}
|
||||
{/* Pro — Popular */}
|
||||
<div className="relative rounded-2xl border-2 border-primary bg-card p-8 flex flex-col shadow-lg shadow-primary/10 scale-105 z-10">
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 rounded-full bg-primary px-4 py-1 text-xs font-bold text-primary-foreground uppercase tracking-wide">
|
||||
{t("landing.pricing.pro.badge")}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{t("pricing.pro.name")}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
{t("pricing.pro.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.pro.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.pro.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.pro.priceYearly") : t("pricing.pro.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.pro.yearly / 12 : prices.pro.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
|
||||
</div>
|
||||
<div className="h-6 mb-4">
|
||||
{isYearly && (
|
||||
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("pricing.pro.billedYearly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<li key={i} className="flex items-center text-foreground text-sm">
|
||||
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
|
||||
{t(`pricing.pro.features.${i}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.pro.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.pro.f1"),
|
||||
t("landing.pricing.pro.f2"),
|
||||
t("landing.pricing.pro.f3"),
|
||||
t("landing.pricing.pro.f4"),
|
||||
t("landing.pricing.pro.f5"),
|
||||
t("landing.pricing.pro.f6"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-foreground font-medium">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
>
|
||||
<Link href="/pricing?plan=pro">{t("common.tryPro")}</Link>
|
||||
<Button asChild className="w-full bg-primary text-primary-foreground font-bold hover:bg-primary/90">
|
||||
<Link href="/pricing?plan=pro">{t("landing.pricing.pro.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Business Plan */}
|
||||
{/* Business */}
|
||||
<div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
{t("pricing.business.name")}
|
||||
</h3>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
{t("pricing.business.description")}
|
||||
<h3 className="text-xl font-semibold">{t("landing.pricing.business.title")}</h3>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{t("landing.pricing.business.desc")}
|
||||
</p>
|
||||
<div className="mt-6 mb-2">
|
||||
<span className="text-4xl font-bold text-foreground">
|
||||
{isYearly ? t("pricing.business.priceYearly") : t("pricing.business.priceMonthly")}
|
||||
<span className="text-4xl font-bold">
|
||||
€{isYearly ? prices.business.yearly / 12 : prices.business.monthly}
|
||||
</span>
|
||||
<span className="text-muted-foreground">/{t("common.month")}</span>
|
||||
<span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
|
||||
</div>
|
||||
<div className="h-6 mb-4">
|
||||
{isYearly && (
|
||||
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
{t("pricing.business.billedYearly")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<li key={i} className="flex items-center text-muted-foreground text-sm">
|
||||
<Check className="h-4 w-4 text-primary mr-2 shrink-0" />
|
||||
{t(`pricing.business.features.${i}`)}
|
||||
{isYearly && (
|
||||
<span className="text-sm text-emerald-600 mb-4">€{prices.business.yearly} {t("landing.pricing.billedYearly")}</span>
|
||||
)}
|
||||
<ul className="space-y-3 mb-8 flex-1 mt-4">
|
||||
{[
|
||||
t("landing.pricing.business.f1"),
|
||||
t("landing.pricing.business.f2"),
|
||||
t("landing.pricing.business.f3"),
|
||||
t("landing.pricing.business.f4"),
|
||||
t("landing.pricing.business.f5"),
|
||||
t("landing.pricing.business.f6"),
|
||||
].map((f, i) => (
|
||||
<li key={i} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/pricing?plan=business">{t("common.tryPro")}</Link>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href="/pricing?plan=business">{t("landing.pricing.business.cta")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Honest pricing callout */}
|
||||
<div className="mt-12 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/5 px-5 py-2.5 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<BadgeCheck className="h-5 w-5" />
|
||||
<span className="font-medium">{t("landing.pricing.honest")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
{/* ── Format Support ── */}
|
||||
<section className="py-24 bg-muted/30" id="formats">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
|
||||
{t("landing.formats.title")}
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{t("landing.formats.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{
|
||||
format: t("landing.formats.word"),
|
||||
items: [
|
||||
t("landing.formats.word.f1"),
|
||||
t("landing.formats.word.f2"),
|
||||
t("landing.formats.word.f3"),
|
||||
t("landing.formats.word.f4"),
|
||||
t("landing.formats.word.f5"),
|
||||
t("landing.formats.word.f6"),
|
||||
t("landing.formats.word.f7"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.excel"),
|
||||
items: [
|
||||
t("landing.formats.excel.f1"),
|
||||
t("landing.formats.excel.f2"),
|
||||
t("landing.formats.excel.f3"),
|
||||
t("landing.formats.excel.f4"),
|
||||
t("landing.formats.excel.f5"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.powerpoint"),
|
||||
items: [
|
||||
t("landing.formats.powerpoint.f1"),
|
||||
t("landing.formats.powerpoint.f2"),
|
||||
t("landing.formats.powerpoint.f3"),
|
||||
t("landing.formats.powerpoint.f4"),
|
||||
t("landing.formats.powerpoint.f5"),
|
||||
],
|
||||
},
|
||||
{
|
||||
format: t("landing.formats.pdf"),
|
||||
items: [
|
||||
t("landing.formats.pdf.f1"),
|
||||
t("landing.formats.pdf.f2"),
|
||||
t("landing.formats.pdf.f3"),
|
||||
t("landing.formats.pdf.f4"),
|
||||
t("landing.formats.pdf.f5"),
|
||||
],
|
||||
},
|
||||
].map((fmt, i) => (
|
||||
<div key={i} className="rounded-2xl border border-border bg-card p-8">
|
||||
<h3 className="text-xl font-bold mb-4">{fmt.format}</h3>
|
||||
<ul className="space-y-2">
|
||||
{fmt.items.map((item, j) => (
|
||||
<li key={j} className="flex items-center text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-primary me-2 shrink-0" />{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CTA ── */}
|
||||
<section className="relative py-20 px-4 bg-background">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-primary/5 to-transparent pointer-events-none" />
|
||||
<div className="mx-auto max-w-4xl rounded-3xl bg-card border border-border p-12 text-center shadow-2xl overflow-hidden relative">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-primary to-transparent opacity-50" />
|
||||
<h2 className="text-3xl font-bold text-foreground mb-6">
|
||||
{t("cta.title")}
|
||||
<h2 className="text-3xl font-bold mb-6">
|
||||
{t("landing.cta.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
|
||||
{t("cta.subtitle")}
|
||||
{t("landing.cta.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
|
||||
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90 text-base px-8"
|
||||
>
|
||||
<Link href="/auth/register">{t("cta.button")}</Link>
|
||||
<Link href="/auth/register">
|
||||
{t("landing.cta.button")}
|
||||
<ArrowRight className="ms-2 h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
{/* ── Footer ── */}
|
||||
<footer className="border-t border-border bg-background py-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||
@@ -396,17 +606,13 @@ export function LandingPage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
||||
<a className="hover:text-foreground transition-colors" href="#">
|
||||
{t("footer.privacy")}
|
||||
</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">
|
||||
{t("footer.terms")}
|
||||
</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">
|
||||
{t("footer.contact")}
|
||||
</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.privacy")}</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.terms")}</a>
|
||||
<a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.contact")}</a>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} Office Translator
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{t("footer.copyright")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useCallback, memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Upload,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Crown,
|
||||
LogOut,
|
||||
BookOpen,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Translate",
|
||||
href: "/",
|
||||
icon: Upload,
|
||||
description: "Translate documents",
|
||||
},
|
||||
{
|
||||
name: "Context",
|
||||
href: "/settings/context",
|
||||
icon: BookOpen,
|
||||
description: "Configure AI instructions & glossary",
|
||||
},
|
||||
];
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
// Memoized NavItem for performance
|
||||
const NavItem = memo(function NavItem({
|
||||
item,
|
||||
isActive
|
||||
}: {
|
||||
item: typeof navigation[0];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
// Listen for storage changes
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "user") {
|
||||
setUser(e.newValue ? JSON.parse(e.newValue) : null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<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">Office Translator</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<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">Office Translator</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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="/settings/subscription"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/settings/subscription"
|
||||
? "bg-violet-500/10 text-violet-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>Mon abonnement</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Gérer votre forfait et votre usage</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{user.plan === "free" && (
|
||||
<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-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
|
||||
)}
|
||||
>
|
||||
<Zap className="h-5 w-5" />
|
||||
<span>Passer Pro</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Voir tous les forfaits disponibles</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* 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">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-teal-500 to-teal-600 text-white text-sm font-medium shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-white truncate">{user.name}</span>
|
||||
<Badge className={cn("text-xs mt-0.5 w-fit", planColors[user.plan] || "bg-zinc-600")}>
|
||||
{{ free: "Gratuit", starter: "Starter", pro: "Pro", business: "Business", enterprise: "Entreprise" }[user.plan] ?? user.plan}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<span className="mr-1 flex-shrink-0">
|
||||
<span className="me-1 flex-shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
<span className="flex items-center gap-1">
|
||||
{children}
|
||||
{displayCount && (
|
||||
<span className="ml-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
<span className="ms-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -137,7 +137,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="ml-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
className="ms-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
aria-label="Remove badge"
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -118,7 +118,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
className="animate-spin -ms-1 me-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-start", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:ps-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:ps-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -80,8 +80,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
"outline-none focus:outline-none",
|
||||
"read-only:cursor-default read-only:bg-surface/50",
|
||||
variants[variant],
|
||||
leftIcon && "pl-10",
|
||||
rightIcon && "pr-10",
|
||||
leftIcon && "ps-10",
|
||||
rightIcon && "pe-10",
|
||||
(leftIcon && rightIcon) && "px-10",
|
||||
error && "text-destructive",
|
||||
className
|
||||
@@ -264,7 +264,7 @@ export const SearchInput = React.forwardRef<
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"pl-10 pr-10",
|
||||
"ps-10 pe-10",
|
||||
focused && "shadow-lg shadow-primary/10",
|
||||
className
|
||||
)}
|
||||
@@ -331,7 +331,7 @@ export const FileInput = React.forwardRef<
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className={cn(
|
||||
"file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
"file:me-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
className
|
||||
)}
|
||||
@@ -344,7 +344,7 @@ export const FileInput = React.forwardRef<
|
||||
/>
|
||||
|
||||
{fileName && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pe-3">
|
||||
<span className="text-xs text-muted-foreground truncate max-w-32">
|
||||
{fileName}
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,17 @@ import { useI18n, type Locale } from "@/lib/i18n";
|
||||
const languages: { value: Locale; label: string; flag: string }[] = [
|
||||
{ value: "en", label: "English", flag: "🇬🇧" },
|
||||
{ value: "fr", label: "Français", flag: "🇫🇷" },
|
||||
{ value: "es", label: "Español", flag: "🇪🇸" },
|
||||
{ value: "de", label: "Deutsch", flag: "🇩🇪" },
|
||||
{ value: "pt", label: "Português", flag: "🇧🇷" },
|
||||
{ value: "it", label: "Italiano", flag: "🇮🇹" },
|
||||
{ value: "nl", label: "Nederlands", flag: "🇳🇱" },
|
||||
{ value: "ru", label: "Русский", flag: "🇷🇺" },
|
||||
{ value: "ja", label: "日本語", flag: "🇯🇵" },
|
||||
{ value: "ko", label: "한국어", flag: "🇰🇷" },
|
||||
{ value: "zh", label: "中文", flag: "🇨🇳" },
|
||||
{ value: "ar", label: "العربية", flag: "🇸🇦" },
|
||||
{ value: "fa", label: "فارسی", flag: "🇮🇷" },
|
||||
];
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
@@ -43,7 +54,7 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
|
||||
<SelectTrigger className="w-[130px] h-9 bg-transparent border-border-dark hover:bg-surface-dark">
|
||||
<SelectTrigger className="w-[150px] h-9">
|
||||
<SelectValue>
|
||||
<span className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
@@ -52,12 +63,11 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-dark border-border-dark">
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.value}
|
||||
value={lang.value}
|
||||
className="hover:bg-primary/10 focus:bg-primary/10"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function ModelCombobox({
|
||||
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
|
||||
)}
|
||||
</div>
|
||||
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ml-2" />}
|
||||
{value === model.id && <Check className="size-3.5 text-primary flex-shrink-0 ms-2" />}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucid
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const notificationVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all duration-300 ease-out",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pe-8 shadow-lg transition-all duration-300 ease-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -109,7 +109,7 @@ function SelectItem({
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pe-8 ps-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"h-10 px-2 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -89,7 +89,7 @@ const TableCell = React.forwardRef<
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pe-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -21,9 +21,9 @@ logger = logging.getLogger(__name__)
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting"""
|
||||
# Requests per window
|
||||
requests_per_minute: int = 30
|
||||
requests_per_hour: int = 200
|
||||
requests_per_day: int = 1000
|
||||
requests_per_minute: int = 120
|
||||
requests_per_hour: int = 1000
|
||||
requests_per_day: int = 5000
|
||||
|
||||
# Translation-specific limits
|
||||
translations_per_minute: int = 10
|
||||
@@ -35,7 +35,7 @@ class RateLimitConfig:
|
||||
max_total_size_per_hour_mb: int = 500
|
||||
|
||||
# Burst protection
|
||||
burst_limit: int = 10 # Max requests in 1 second
|
||||
burst_limit: int = 30 # Max requests in 1 second
|
||||
|
||||
# Whitelist IPs (no rate limiting)
|
||||
whitelist_ips: list = field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||
@@ -270,6 +270,23 @@ class RateLimitManager:
|
||||
async def check_request(self, request: Request) -> tuple[bool, str, str]:
|
||||
"""Check if request is allowed, return (allowed, reason, client_id)"""
|
||||
client_id = self.get_client_id(request)
|
||||
|
||||
# Prefer user ID for authenticated users (avoids shared limits behind proxy)
|
||||
# Try to extract from already-set state (auth middleware ran first)
|
||||
user_id = None
|
||||
if hasattr(request, "state"):
|
||||
user_id = getattr(request.state, "client_id", None)
|
||||
if not user_id:
|
||||
# Try to get user from auth header for better per-user limiting
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
# Use a hash of the token as user identifier (no decoding needed)
|
||||
import hashlib
|
||||
token = auth_header[7:]
|
||||
user_id = f"tok:{hashlib.sha256(token.encode()).hexdigest()[:16]}"
|
||||
if user_id:
|
||||
client_id = user_id
|
||||
|
||||
self._total_requests += 1
|
||||
|
||||
if self.is_whitelisted(client_id):
|
||||
@@ -387,6 +404,19 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if request.url.path.startswith("/static"):
|
||||
return await call_next(request)
|
||||
|
||||
# Skip rate limiting for lightweight GET endpoints (read-only, cacheable)
|
||||
# These are config/fetch endpoints that don't consume resources
|
||||
if request.method == "GET":
|
||||
skip_paths = (
|
||||
"/api/v1/languages",
|
||||
"/api/v1/providers",
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/usage",
|
||||
"/api/v1/translations/", # status polling (uses job_id suffix)
|
||||
)
|
||||
if any(request.url.path.startswith(p) for p in skip_paths):
|
||||
return await call_next(request)
|
||||
|
||||
# Check rate limit
|
||||
allowed, reason, client_id = await self.manager.check_request(request)
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""
|
||||
Tier-based daily translation quota (Story 1.6).
|
||||
Uses Redis sliding-window daily counter per user; fallback in-memory when Redis unavailable.
|
||||
Tier-based monthly translation quota.
|
||||
Uses Redis counter per user per month; fallback in-memory when Redis unavailable.
|
||||
Coexists with IP-based rate limiting in rate_limiting.py.
|
||||
|
||||
Source of truth: Redis (key per user per UTC date) is the authority for quota enforcement.
|
||||
User.daily_translation_count in DB is kept in sync on each successful translation for
|
||||
reporting/analytics; reset at midnight UTC is automatic in Redis (new key per day). DB
|
||||
reset can be done by a scheduled job at midnight UTC if needed.
|
||||
Free tier: 2 translations per calendar month.
|
||||
Paid tiers (starter/pro/business/enterprise): unlimited.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,65 +16,65 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Free tier: 5 translations per day (UTC). Pro (and equivalent) tiers: no daily cap.
|
||||
FREE_TIER_DAILY_LIMIT = 5
|
||||
KEY_PREFIX = "rate_limit:daily"
|
||||
# Free tier: 2 translations per calendar month.
|
||||
FREE_TIER_MONTHLY_LIMIT = 2
|
||||
KEY_PREFIX = "quota:monthly"
|
||||
|
||||
|
||||
def _utc_date_str(dt: Optional[datetime] = None) -> str:
|
||||
"""Current date in UTC as YYYY-MM-DD."""
|
||||
def _utc_month_str(dt: Optional[datetime] = None) -> str:
|
||||
"""Current month in UTC as YYYY-MM."""
|
||||
t = dt or datetime.now(timezone.utc)
|
||||
return t.strftime("%Y-%m-%d")
|
||||
return t.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _next_midnight_utc(dt: Optional[datetime] = None) -> datetime:
|
||||
"""Next midnight UTC after the given time (or now)."""
|
||||
def _next_month_utc(dt: Optional[datetime] = None) -> datetime:
|
||||
"""First second of next month UTC."""
|
||||
now = dt or datetime.now(timezone.utc)
|
||||
tomorrow = (now.date() + timedelta(days=1))
|
||||
return datetime(tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=timezone.utc)
|
||||
if now.month == 12:
|
||||
return datetime(now.year + 1, 1, 1, tzinfo=timezone.utc)
|
||||
return datetime(now.year, now.month + 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _seconds_until_midnight_utc(dt: Optional[datetime] = None) -> int:
|
||||
"""Seconds until next midnight UTC."""
|
||||
def _seconds_until_next_month(dt: Optional[datetime] = None) -> int:
|
||||
"""Seconds until start of next month UTC."""
|
||||
now = dt or datetime.now(timezone.utc)
|
||||
return max(0, int((_next_midnight_utc(now) - now).total_seconds()))
|
||||
return max(0, int((_next_month_utc(now) - now).total_seconds()))
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaResult:
|
||||
"""Result of a quota check."""
|
||||
allowed: bool
|
||||
remaining: int # -1 for pro (unlimited)
|
||||
remaining: int # -1 for paid tiers (unlimited)
|
||||
reset_at_utc: datetime
|
||||
current_usage: int = 0
|
||||
limit: int = FREE_TIER_DAILY_LIMIT
|
||||
limit: int = FREE_TIER_MONTHLY_LIMIT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis backend (shared client from core.redis)
|
||||
# Redis backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_async_redis():
|
||||
"""Return async Redis client or None. Uses shared client from core.redis."""
|
||||
from core.redis import get_async_redis
|
||||
|
||||
return get_async_redis()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory fallback (per process; not shared across workers). Documented as fallback.
|
||||
# In-memory fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_memory_usage: dict[tuple[str, str], int] = {} # (user_id, date_utc_str) -> count
|
||||
_memory_usage: dict[tuple[str, str], int] = {} # (user_id, month_str) -> count
|
||||
|
||||
|
||||
def _memory_get(user_id: str, date_str: str) -> int:
|
||||
return _memory_usage.get((user_id, date_str), 0)
|
||||
def _memory_get(user_id: str, month_str: str) -> int:
|
||||
return _memory_usage.get((user_id, month_str), 0)
|
||||
|
||||
|
||||
def _memory_incr(user_id: str, date_str: str) -> int:
|
||||
key = (user_id, date_str)
|
||||
def _memory_incr(user_id: str, month_str: str) -> int:
|
||||
key = (user_id, month_str)
|
||||
_memory_usage[key] = _memory_usage.get(key, 0) + 1
|
||||
return _memory_usage[key]
|
||||
|
||||
@@ -87,28 +85,21 @@ def _memory_incr(user_id: str, date_str: str) -> int:
|
||||
|
||||
class TierQuotaService:
|
||||
"""
|
||||
Daily translation quota per user by tier.
|
||||
Redis key pattern: rate_limit:daily:{user_id}:{YYYY-MM-DD}, TTL 25h.
|
||||
If Redis is unavailable, uses in-memory dict (documented fallback).
|
||||
Monthly translation quota per user by tier.
|
||||
Redis key pattern: quota:monthly:{user_id}:{YYYY-MM}, TTL 32 days.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._redis = None # Lazy init on first use
|
||||
self._redis = None
|
||||
|
||||
def _redis_client(self):
|
||||
if self._redis is None:
|
||||
self._redis = _get_async_redis()
|
||||
return self._redis
|
||||
|
||||
def _date_str(self, dt: Optional[datetime] = None) -> str:
|
||||
return _utc_date_str(dt)
|
||||
|
||||
async def check_quota(self, user_id: str, tier: str) -> QuotaResult:
|
||||
"""
|
||||
Check if user has quota for one more translation today (UTC).
|
||||
tier "free" -> limit 5/day; "pro" (or equivalent) -> unlimited.
|
||||
"""
|
||||
reset_at = _next_midnight_utc()
|
||||
"""Check monthly quota. Free = 2/month; paid = unlimited."""
|
||||
reset_at = _next_month_utc()
|
||||
tier_lower = (tier or "free").lower()
|
||||
if tier_lower in ("pro", "business", "enterprise", "starter"):
|
||||
return QuotaResult(
|
||||
@@ -118,48 +109,48 @@ class TierQuotaService:
|
||||
current_usage=0,
|
||||
limit=0,
|
||||
)
|
||||
# Free tier
|
||||
date_str = self._date_str()
|
||||
# Free tier — monthly counter
|
||||
month_str = _utc_month_str()
|
||||
redis_client = self._redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = f"{KEY_PREFIX}:{user_id}:{date_str}"
|
||||
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
|
||||
count = await redis_client.get(key)
|
||||
count = int(count or 0)
|
||||
except Exception as e:
|
||||
logger.warning("Tier quota Redis get failed: %s, using in-memory", e)
|
||||
count = _memory_get(user_id, date_str)
|
||||
count = _memory_get(user_id, month_str)
|
||||
else:
|
||||
count = _memory_get(user_id, date_str)
|
||||
remaining = max(0, FREE_TIER_DAILY_LIMIT - count)
|
||||
count = _memory_get(user_id, month_str)
|
||||
remaining = max(0, FREE_TIER_MONTHLY_LIMIT - count)
|
||||
return QuotaResult(
|
||||
allowed=count < FREE_TIER_DAILY_LIMIT,
|
||||
allowed=count < FREE_TIER_MONTHLY_LIMIT,
|
||||
remaining=remaining,
|
||||
reset_at_utc=reset_at,
|
||||
current_usage=count,
|
||||
limit=FREE_TIER_DAILY_LIMIT,
|
||||
limit=FREE_TIER_MONTHLY_LIMIT,
|
||||
)
|
||||
|
||||
async def increment_on_success(self, user_id: str) -> None:
|
||||
"""Increment daily translation count for user (call after successful translation)."""
|
||||
date_str = self._date_str()
|
||||
"""Increment monthly translation count (call after successful translation)."""
|
||||
month_str = _utc_month_str()
|
||||
redis_client = self._redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = f"{KEY_PREFIX}:{user_id}:{date_str}"
|
||||
key = f"{KEY_PREFIX}:{user_id}:{month_str}"
|
||||
pipe = redis_client.pipeline()
|
||||
pipe.incr(key)
|
||||
pipe.expire(key, 25 * 3600) # 25h so key expires after midnight UTC
|
||||
pipe.expire(key, 32 * 24 * 3600) # 32 days
|
||||
await pipe.execute()
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Tier quota Redis increment failed: %s, using in-memory", e)
|
||||
_memory_incr(user_id, date_str)
|
||||
_memory_incr(user_id, month_str)
|
||||
|
||||
def seconds_until_reset(self) -> int:
|
||||
"""Seconds until next midnight UTC (for Retry-After header)."""
|
||||
return _seconds_until_midnight_utc()
|
||||
"""Seconds until start of next month UTC."""
|
||||
return _seconds_until_next_month()
|
||||
|
||||
|
||||
# Singleton for app use
|
||||
# Singleton
|
||||
tier_quota_service = TierQuotaService()
|
||||
|
||||
@@ -57,10 +57,12 @@ class FileValidator:
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
"application/pdf": ".pdf",
|
||||
}
|
||||
|
||||
# Magic bytes for Office Open XML files (ZIP format)
|
||||
OFFICE_MAGIC_BYTES = b"PK\x03\x04"
|
||||
PDF_MAGIC_BYTES = b"%PDF"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -70,7 +72,7 @@ class FileValidator:
|
||||
):
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.max_size_mb = max_size_mb
|
||||
self.allowed_extensions = allowed_extensions or {".xlsx", ".docx", ".pptx"}
|
||||
self.allowed_extensions = allowed_extensions or {".xlsx", ".docx", ".pptx", ".pdf"}
|
||||
self.scan_content = scan_content
|
||||
|
||||
async def validate_async(self, file: UploadFile) -> ValidationResult:
|
||||
@@ -281,11 +283,20 @@ class FileValidator:
|
||||
|
||||
def _validate_magic_bytes(self, content: bytes, extension: str):
|
||||
"""Validate file magic bytes match expected format"""
|
||||
# All supported formats are Office Open XML (ZIP-based)
|
||||
# PDF files start with %PDF
|
||||
if extension.lower() == ".pdf":
|
||||
if not content.startswith(self.PDF_MAGIC_BYTES):
|
||||
raise ValidationError(
|
||||
"File content does not match expected PDF format. "
|
||||
"The file may be corrupted.",
|
||||
code="invalid_file_content",
|
||||
)
|
||||
return
|
||||
# Office files are ZIP-based
|
||||
if not content.startswith(self.OFFICE_MAGIC_BYTES):
|
||||
raise ValidationError(
|
||||
"Le contenu du fichier ne correspond pas au format attendu. "
|
||||
"Le fichier est peut-etre corrompu ou n'est pas un document Office valide.",
|
||||
"File content does not match expected Office format. "
|
||||
"The file may be corrupted or not a valid Office document.",
|
||||
code="invalid_file_content",
|
||||
)
|
||||
|
||||
|
||||
@@ -37,28 +37,29 @@ import os
|
||||
# or google/gemini-3-flash ($0.15 / $0.60)
|
||||
PLANS = {
|
||||
PlanType.FREE: {
|
||||
"name": "Gratuit",
|
||||
"name": "Free",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"docs_per_month": 5,
|
||||
"max_pages_per_doc": 15,
|
||||
"docs_per_month": 2,
|
||||
"max_pages_per_doc": 10,
|
||||
"max_file_size_mb": 5,
|
||||
"max_chars_per_month": 50_000,
|
||||
"max_chars_per_month": 20_000,
|
||||
"providers": ["google"],
|
||||
"features": [
|
||||
"5 documents / mois",
|
||||
"Jusqu'à 15 pages par document",
|
||||
"Google Traduction inclus",
|
||||
"Toutes les langues (130+)",
|
||||
"Support communautaire",
|
||||
"pricing.plans.free.feat1",
|
||||
"pricing.plans.free.feat2",
|
||||
"pricing.plans.free.feat3",
|
||||
"pricing.plans.free.feat4",
|
||||
"pricing.plans.free.feat5",
|
||||
],
|
||||
"ai_translation": False,
|
||||
"api_access": False,
|
||||
"priority_processing": False,
|
||||
"watermark": True,
|
||||
"stripe_price_id_monthly": None,
|
||||
"stripe_price_id_yearly": None,
|
||||
"highlight": None,
|
||||
"description": "Parfait pour découvrir l'application",
|
||||
"description": "pricing.plans.free.description",
|
||||
"badge": None,
|
||||
},
|
||||
PlanType.STARTER: {
|
||||
@@ -71,12 +72,12 @@ PLANS = {
|
||||
"max_chars_per_month": 500_000,
|
||||
"providers": ["google", "deepl"],
|
||||
"features": [
|
||||
"50 documents / mois",
|
||||
"Jusqu'à 50 pages par document",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 10 Mo",
|
||||
"Support par e-mail",
|
||||
"Historique 30 jours",
|
||||
"pricing.plans.starter.feat1",
|
||||
"pricing.plans.starter.feat2",
|
||||
"pricing.plans.starter.feat3",
|
||||
"pricing.plans.starter.feat4",
|
||||
"pricing.plans.starter.feat5",
|
||||
"pricing.plans.starter.feat6",
|
||||
],
|
||||
"ai_translation": False,
|
||||
"api_access": False,
|
||||
@@ -84,7 +85,7 @@ PLANS = {
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_STARTER_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_STARTER_YEARLY", ""),
|
||||
"highlight": None,
|
||||
"description": "Pour les particuliers et petits projets",
|
||||
"description": "pricing.plans.starter.description",
|
||||
"badge": None,
|
||||
},
|
||||
PlanType.PRO: {
|
||||
@@ -98,14 +99,14 @@ PLANS = {
|
||||
"providers": ["google", "google_cloud", "deepl", "openrouter"],
|
||||
"ai_model_essential": "deepseek/deepseek-v3.2",
|
||||
"features": [
|
||||
"200 documents / mois",
|
||||
"Jusqu'à 200 pages par document",
|
||||
"Traduction IA Essentielle incluse (DeepSeek V3.2)",
|
||||
"Google Traduction + DeepL",
|
||||
"Fichiers jusqu'à 25 Mo",
|
||||
"Glossaires personnalisés",
|
||||
"Support prioritaire par e-mail",
|
||||
"Historique 90 jours",
|
||||
"pricing.plans.pro.feat1",
|
||||
"pricing.plans.pro.feat2",
|
||||
"pricing.plans.pro.feat3",
|
||||
"pricing.plans.pro.feat4",
|
||||
"pricing.plans.pro.feat5",
|
||||
"pricing.plans.pro.feat6",
|
||||
"pricing.plans.pro.feat7",
|
||||
"pricing.plans.pro.feat8",
|
||||
],
|
||||
"ai_translation": True,
|
||||
"ai_tier": "essential",
|
||||
@@ -113,9 +114,9 @@ PLANS = {
|
||||
"priority_processing": True,
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_PRO_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_PRO_YEARLY", ""),
|
||||
"highlight": "Le plus populaire",
|
||||
"description": "Pour les professionnels et équipes en croissance",
|
||||
"badge": "POPULAIRE",
|
||||
"highlight": "pricing.plans.pro.highlight",
|
||||
"description": "pricing.plans.pro.description",
|
||||
"badge": "POPULAR",
|
||||
},
|
||||
PlanType.BUSINESS: {
|
||||
"name": "Business",
|
||||
@@ -129,17 +130,16 @@ PLANS = {
|
||||
"ai_model_essential": "deepseek/deepseek-v3.2",
|
||||
"ai_model_premium": "anthropic/claude-3.5-haiku",
|
||||
"features": [
|
||||
"1 000 documents / mois",
|
||||
"Jusqu'à 500 pages par document",
|
||||
"Traduction IA Essentielle + Premium (Claude Haiku)",
|
||||
"Tous les fournisseurs de traduction",
|
||||
"Fichiers jusqu'à 50 Mo",
|
||||
"Accès API (10 000 appels/mois)",
|
||||
"Webhooks de notification",
|
||||
"Glossaires + Prompts personnalisés",
|
||||
"Support dédié",
|
||||
"Historique 1 an",
|
||||
"Analytiques avancées",
|
||||
"pricing.plans.business.feat1",
|
||||
"pricing.plans.business.feat2",
|
||||
"pricing.plans.business.feat3",
|
||||
"pricing.plans.business.feat4",
|
||||
"pricing.plans.business.feat5",
|
||||
"pricing.plans.business.feat6",
|
||||
"pricing.plans.business.feat7",
|
||||
"pricing.plans.business.feat8",
|
||||
"pricing.plans.business.feat9",
|
||||
"pricing.plans.business.feat10",
|
||||
],
|
||||
"ai_translation": True,
|
||||
"ai_tier": "premium",
|
||||
@@ -150,11 +150,11 @@ PLANS = {
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_BUSINESS_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_BUSINESS_YEARLY", ""),
|
||||
"highlight": None,
|
||||
"description": "Pour les équipes et organisations",
|
||||
"description": "pricing.plans.business.description",
|
||||
"badge": None,
|
||||
},
|
||||
PlanType.ENTERPRISE: {
|
||||
"name": "Entreprise",
|
||||
"name": "Enterprise",
|
||||
"price_monthly": -1,
|
||||
"price_yearly": -1,
|
||||
"docs_per_month": -1,
|
||||
@@ -163,15 +163,14 @@ PLANS = {
|
||||
"max_chars_per_month": -1,
|
||||
"providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai", "custom"],
|
||||
"features": [
|
||||
"Documents illimités",
|
||||
"Tous les modèles IA (GPT-5, Claude Opus 4.6...)",
|
||||
"Déploiement on-premise ou cloud dédié",
|
||||
"SLA 99,9 % garanti",
|
||||
"Support 24/7 dédié",
|
||||
"Modèles IA personnalisés",
|
||||
"Marque blanche (white-label)",
|
||||
"Équipes illimitées",
|
||||
"Intégrations sur mesure",
|
||||
"pricing.plans.enterprise.feat1",
|
||||
"pricing.plans.enterprise.feat2",
|
||||
"pricing.plans.enterprise.feat3",
|
||||
"pricing.plans.enterprise.feat4",
|
||||
"pricing.plans.enterprise.feat5",
|
||||
"pricing.plans.enterprise.feat6",
|
||||
"pricing.plans.enterprise.feat7",
|
||||
"pricing.plans.enterprise.feat8",
|
||||
],
|
||||
"ai_translation": True,
|
||||
"ai_tier": "custom",
|
||||
@@ -182,8 +181,8 @@ PLANS = {
|
||||
"stripe_price_id_monthly": None,
|
||||
"stripe_price_id_yearly": None,
|
||||
"highlight": None,
|
||||
"description": "Solutions sur mesure pour grandes organisations",
|
||||
"badge": "SUR DEVIS",
|
||||
"description": "pricing.plans.enterprise.description",
|
||||
"badge": "ON REQUEST",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -243,13 +242,13 @@ class UserCreate(BaseModel):
|
||||
def validate_password_strength(cls, v: str) -> str:
|
||||
"""Validate password meets minimum security requirements."""
|
||||
if len(v) < 8:
|
||||
raise ValueError("Le mot de passe doit contenir au moins 8 caractères")
|
||||
raise ValueError("Password must be at least 8 characters")
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Le mot de passe doit contenir au moins une majuscule")
|
||||
raise ValueError("Password must contain at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Le mot de passe doit contenir au moins une minuscule")
|
||||
raise ValueError("Password must contain at least one lowercase letter")
|
||||
if not re.search(r"[0-9]", v):
|
||||
raise ValueError("Le mot de passe doit contenir au moins un chiffre")
|
||||
raise ValueError("Password must contain at least one digit")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ python-multipart==0.0.9
|
||||
openpyxl==3.1.2
|
||||
python-docx==1.1.0
|
||||
python-pptx==0.6.23
|
||||
pdf2docx>=0.5.6
|
||||
PyMuPDF>=1.24.0
|
||||
lxml>=4.9.0
|
||||
reportlab>=4.0.0
|
||||
deep-translator==1.11.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
|
||||
@@ -592,6 +592,34 @@ async def get_tracked_files(is_admin: bool = Depends(require_admin)):
|
||||
return {"count": len(tracked), "files": tracked}
|
||||
|
||||
|
||||
@router.post("/quota/reset")
|
||||
async def reset_translation_quotas(is_admin: bool = Depends(require_admin)):
|
||||
"""Reset monthly translation quotas for all free-tier users.
|
||||
Clears Redis keys matching quota:monthly:*
|
||||
"""
|
||||
try:
|
||||
from core.redis import get_async_redis
|
||||
redis_client = get_async_redis()
|
||||
if not redis_client:
|
||||
return {"status": "skipped", "message": "Redis not available, quotas are in-memory only"}
|
||||
|
||||
# Find all monthly quota keys
|
||||
keys = []
|
||||
async for key in redis_client.scan_iter(match="quota:monthly:*"):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await redis_client.delete(*keys)
|
||||
logger.info("admin_quota_reset", keys_deleted=deleted)
|
||||
return {"status": "success", "keys_deleted": deleted, "message": f"Reset {deleted} quota counters"}
|
||||
else:
|
||||
return {"status": "success", "keys_deleted": 0, "message": "No active quotas to reset"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Admin quota reset failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Quota reset failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/config/provider")
|
||||
async def update_default_provider(
|
||||
provider: str = Form(...),
|
||||
|
||||
@@ -11,17 +11,15 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.auth_service import verify_token, get_user_by_id
|
||||
from routes.deps import ProUser, require_pro_user
|
||||
from database.connection import get_sync_session
|
||||
from database.models import ApiKey
|
||||
|
||||
router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
MAX_API_KEYS_PER_USER = 10
|
||||
|
||||
@@ -38,95 +36,6 @@ class ApiKeyResponse(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
class ProUser:
|
||||
"""Wrapper for authenticated Pro user with tier info"""
|
||||
|
||||
def __init__(self, user):
|
||||
self._user = user
|
||||
self.id = user.id
|
||||
self.email = getattr(user, "email", None)
|
||||
self._tier = None
|
||||
|
||||
@property
|
||||
def tier(self) -> str:
|
||||
if self._tier is None:
|
||||
user_tier = getattr(self._user, "tier", None)
|
||||
if user_tier:
|
||||
self._tier = user_tier
|
||||
else:
|
||||
plan_value = getattr(self._user, "plan", None)
|
||||
if plan_value and hasattr(plan_value, "value"):
|
||||
if plan_value.value in ("pro", "business", "enterprise"):
|
||||
self._tier = "pro"
|
||||
else:
|
||||
self._tier = "free"
|
||||
else:
|
||||
self._tier = "free"
|
||||
return self._tier
|
||||
|
||||
|
||||
def _require_auth(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
):
|
||||
"""Dependency that requires a valid JWT token (any authenticated user)"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Authentification requise",
|
||||
},
|
||||
)
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Token invalide ou expiré",
|
||||
},
|
||||
)
|
||||
|
||||
sub = payload.get("sub")
|
||||
if not sub or not isinstance(sub, str):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Token invalide",
|
||||
},
|
||||
)
|
||||
|
||||
user = get_user_by_id(sub)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": "UNAUTHORIZED",
|
||||
"message": "Utilisateur non trouvé",
|
||||
},
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _require_pro_user(user=Depends(_require_auth)) -> ProUser:
|
||||
"""Dependency that requires a valid Pro user JWT token"""
|
||||
pro_user = ProUser(user)
|
||||
|
||||
if pro_user.tier != "pro":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "PRO_FEATURE_REQUIRED",
|
||||
"message": "Cette fonctionnalité nécessite un abonnement Pro",
|
||||
},
|
||||
)
|
||||
|
||||
return pro_user
|
||||
|
||||
|
||||
def _generate_api_key() -> tuple[str, str, str]:
|
||||
"""
|
||||
Generate a secure API key with sk_live_ prefix.
|
||||
@@ -146,7 +55,7 @@ def _generate_api_key() -> tuple[str, str, str]:
|
||||
async def create_api_key(
|
||||
request: Request,
|
||||
body: Optional[ApiKeyCreateRequest] = None,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
Create a new API key for the authenticated Pro user.
|
||||
@@ -213,7 +122,7 @@ async def create_api_key(
|
||||
@router.get("")
|
||||
async def list_api_keys(
|
||||
request: Request,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
List all API keys for the authenticated Pro user.
|
||||
@@ -260,7 +169,7 @@ async def list_api_keys(
|
||||
@router.delete("/{key_id}")
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
Revoke an API key for the authenticated Pro user.
|
||||
|
||||
@@ -161,7 +161,7 @@ Créer un nouveau compte utilisateur.
|
||||
- Le `tier` par défaut est "free"
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `INVALID_EMAIL`: Invalid email format
|
||||
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
|
||||
- `INVALID_REQUEST`: Données d'inscription invalides
|
||||
""",
|
||||
@@ -187,17 +187,17 @@ Créer un nouveau compte utilisateur.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Format d'email invalide",
|
||||
"summary": "Invalid email format",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
},
|
||||
"EMAIL_EXISTS": {
|
||||
"summary": "Email déjà utilisé",
|
||||
"summary": "Email already used",
|
||||
"value": {
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
"message": "An account already exists with this email address",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -215,7 +215,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -227,7 +227,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
)
|
||||
# Check if it's a password validation error
|
||||
@@ -241,7 +241,7 @@ async def register_v1(request: Request):
|
||||
# Otherwise it's a weak password
|
||||
# Translate common pydantic messages to French
|
||||
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
|
||||
msg = "Le mot de passe doit contenir au moins 8 caractères"
|
||||
msg = "Password must be at least 8 characters long"
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -253,7 +253,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
"message": "Invalid registration data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -266,7 +266,7 @@ async def register_v1(request: Request):
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "AUTH_HASHING_UNAVAILABLE",
|
||||
"message": "Service d'inscription temporairement indisponible",
|
||||
"message": "Registration service temporarily unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -279,14 +279,14 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
"message": "An account already exists with this email address",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "REGISTRATION_FAILED",
|
||||
"message": "Impossible de créer le compte avec les données fournies",
|
||||
"message": "Unable to create account with the provided data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -318,7 +318,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
- HTTP 200 avec message de confirmation
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `TOKEN_MISSING`: Token d'authentification manquant
|
||||
- `TOKEN_MISSING`: Authentication token missing
|
||||
- `TOKEN_INVALID`: Token invalide ou expiré
|
||||
""",
|
||||
responses={
|
||||
@@ -326,7 +326,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
"description": "Déconnexion réussie",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
|
||||
"example": {"data": {"message": "Logged out successfully"}, "meta": {}}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -336,17 +336,17 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"TOKEN_MISSING": {
|
||||
"summary": "Token manquant",
|
||||
"summary": "Missing token",
|
||||
"value": {
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
"message": "Authentication token required",
|
||||
},
|
||||
},
|
||||
"TOKEN_INVALID": {
|
||||
"summary": "Token invalide",
|
||||
"summary": "Invalid token",
|
||||
"value": {
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -363,7 +363,7 @@ async def logout_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
"message": "Authentication token required",
|
||||
},
|
||||
)
|
||||
access_token = auth_header[7:]
|
||||
@@ -374,7 +374,7 @@ async def logout_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -397,7 +397,7 @@ async def logout_v1(request: Request):
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
|
||||
content={"data": {"message": "Logged out successfully"}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_REQUEST`: Corps de requête JSON invalide
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `INVALID_EMAIL`: Invalid email format
|
||||
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
|
||||
""",
|
||||
responses={
|
||||
@@ -441,17 +441,17 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_REQUEST": {
|
||||
"summary": "Corps invalide",
|
||||
"summary": "Invalid body",
|
||||
"value": {
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
},
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Email invalide",
|
||||
"summary": "Invalid email",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -464,7 +464,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -480,7 +480,7 @@ async def login_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -492,14 +492,14 @@ async def login_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
"message": "Invalid registration data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -511,7 +511,7 @@ async def login_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -520,7 +520,7 @@ async def login_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -552,7 +552,7 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
if not body.credential and not body.access_token:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_TOKEN", "message": "credential ou access_token requis."},
|
||||
content={"error": "MISSING_TOKEN", "message": "credential or access_token required."},
|
||||
)
|
||||
|
||||
google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
@@ -574,13 +574,13 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "GOOGLE_UNREACHABLE", "message": "Impossible de contacter les serveurs Google."},
|
||||
content={"error": "GOOGLE_UNREACHABLE", "message": "Unable to contact Google servers."},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Token Google invalide ou expiré."},
|
||||
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Invalid or expired Google token."},
|
||||
)
|
||||
|
||||
google_data = resp.json()
|
||||
@@ -589,14 +589,14 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
if body.credential and google_client_id and google_data.get("aud") != google_client_id:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Token Google non destiné à cette application."},
|
||||
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Google token not intended for this application."},
|
||||
)
|
||||
|
||||
email = google_data.get("email")
|
||||
if not email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_EMAIL", "message": "Email non fourni par Google."},
|
||||
content={"error": "MISSING_EMAIL", "message": "Email not provided by Google."},
|
||||
)
|
||||
|
||||
name = google_data.get("name") or google_data.get("given_name") or email.split("@")[0]
|
||||
@@ -636,7 +636,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -645,7 +645,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -659,7 +659,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Refresh token requis",
|
||||
"message": "Refresh token required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -669,7 +669,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -678,7 +678,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -688,7 +688,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -893,7 +893,7 @@ async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "BILLING_UNAVAILABLE",
|
||||
"message": "Portail de facturation non disponible",
|
||||
"message": "Billing portal unavailable",
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
@@ -928,7 +928,7 @@ async def forgot_password(request: Request):
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "EMAIL_SERVICE_UNAVAILABLE",
|
||||
"message": "Service email non configure",
|
||||
"message": "Email service not configured",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -939,7 +939,7 @@ async def forgot_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -949,7 +949,7 @@ async def forgot_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Email requis",
|
||||
"message": "Email required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1015,7 +1015,7 @@ async def forgot_password(request: Request):
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
|
||||
"message": "If an account exists with this email, a reset email has been sent."
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
@@ -1041,7 +1041,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1053,7 +1053,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Token et mot de passe requis",
|
||||
"message": "Token and password required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1063,7 +1063,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "WEAK_PASSWORD",
|
||||
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
|
||||
"message": "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1085,7 +1085,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_TOKEN",
|
||||
"message": "Token invalide ou expire",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1099,7 +1099,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token expire, veuillez redemander une reinitialisation",
|
||||
"message": "Token expired, please request a new reset",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1116,14 +1116,14 @@ async def reset_password(request: Request):
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "RESET_FAILED",
|
||||
"message": "Erreur lors de la reinitialisation",
|
||||
"message": "Error during password reset",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {"message": "Mot de passe reinitialise avec succes"},
|
||||
"data": {"message": "Password reset successfully"},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user