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:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

10
.gitattributes vendored Normal file
View 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
View 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
View 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.*

View 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'))"
)

View File

@@ -32,7 +32,7 @@ class Config:
LOGS_DIR = BASE_DIR / "logs" LOGS_DIR = BASE_DIR / "logs"
# Supported file types # Supported file types
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"} SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx", ".pdf"}
# ============== Rate Limiting (SaaS) ============== # ============== Rate Limiting (SaaS) ==============
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true" RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"

View File

@@ -100,7 +100,7 @@ class User(Base):
api_keys = relationship("ApiKey", back_populates="user", lazy="select") api_keys = relationship("ApiKey", back_populates="user", lazy="select")
__table_args__ = ( __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_email_active", "email", "is_active"),
Index("ix_users_stripe_customer", "stripe_customer_id"), Index("ix_users_stripe_customer", "stripe_customer_id"),
) )

View File

@@ -40,7 +40,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
container_name: translate-redis container_name: translate-redis
restart: unless-stopped 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: volumes:
- redis_data:/data - redis_data:/data
ports: ports:

View 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

View File

@@ -1,11 +1,15 @@
# Document Translation API - Production Docker Compose # ============================================
# Usage: docker compose up -d # Wordly.art - Production Docker Compose
# (or: docker-compose up -d) # ============================================
# 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). # Usage:
# Secrets: set in .env at project root (see .env.example "Production / VPS" section). # docker compose up -d
#
version: '3.8' # Puis configurer NPM pour pointer vers:
# wordly.art -> frontend:3000 (et backend:8000 pour /api/ et /translate)
# ============================================
services: services:
# =========================================== # ===========================================
@@ -13,18 +17,17 @@ services:
# =========================================== # ===========================================
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: translate-postgres container_name: wordly-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_USER=${POSTGRES_USER:-translate} - POSTGRES_USER=${POSTGRES_USER:-translate}
# Production: set POSTGRES_PASSWORD in .env (required, no default — NFR10)
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB:-translate_db} - POSTGRES_DB=${POSTGRES_DB:-translate_db}
- PGDATA=/var/lib/postgresql/data/pgdata - PGDATA=/var/lib/postgresql/data/pgdata
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: networks:
- translate-network - wordly-network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
interval: 10s interval: 10s
@@ -39,41 +42,53 @@ services:
memory: 128M 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: backend:
build: build:
context: . context: .
dockerfile: docker/backend/Dockerfile dockerfile: docker/backend/Dockerfile
target: production target: production
container_name: translate-backend container_name: wordly-backend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: 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} - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-translate_db}
# Redis
- REDIS_URL=redis://redis:6379/0 - REDIS_URL=redis://redis:6379/0
# Translation Services
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama} - TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434} - OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3} - OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
- DEEPL_API_KEY=${DEEPL_API_KEY:-} - DEEPL_API_KEY=${DEEPL_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
# File Limits
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50} - MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
# Rate Limiting
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60} - RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10} - RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
# Admin Auth (CHANGE IN PRODUCTION!)
- ADMIN_USERNAME=${ADMIN_USERNAME} - ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
# Security (.env.example documents JWT_SECRET_KEY)
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
- CORS_ORIGINS=${CORS_ORIGINS:-https://yourdomain.com} - ADMIN_TOKEN_SECRET=${ADMIN_TOKEN_SECRET}
# Stripe Payments - CORS_ORIGINS=${CORS_ORIGINS:-https://wordly.art}
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-} - STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
@@ -84,7 +99,7 @@ services:
- outputs_data:/app/outputs - outputs_data:/app/outputs
- logs_data:/app/logs - logs_data:/app/logs
networks: networks:
- translate-network - wordly-network
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -95,7 +110,7 @@ services:
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 15s
deploy: deploy:
resources: resources:
limits: limits:
@@ -104,23 +119,23 @@ services:
memory: 512M memory: 512M
# =========================================== # ===========================================
# Frontend Web Service # Frontend (Next.js)
# =========================================== # ===========================================
frontend: frontend:
build: build:
context: . context: .
dockerfile: docker/frontend/Dockerfile dockerfile: docker/frontend/Dockerfile
target: production target: production
args: args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000} NEXT_PUBLIC_API_URL: ""
container_name: translate-frontend container_name: wordly-frontend
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment: environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000} - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-https://wordly.art}
networks: networks:
- translate-network - wordly-network
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -131,44 +146,17 @@ services:
reservations: reservations:
memory: 128M 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 (Optional - Local LLM)
# =========================================== # ===========================================
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:latest
container_name: translate-ollama container_name: wordly-ollama
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ollama_data:/root/.ollama - ollama_data:/root/.ollama
networks: networks:
- translate-network - wordly-network
deploy: deploy:
resources: resources:
reservations: reservations:
@@ -179,74 +167,13 @@ services:
profiles: profiles:
- with-ollama - 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
# =========================================== # ===========================================
networks: networks:
translate-network: wordly-network:
driver: bridge driver: bridge
ipam: name: wordly-network
config:
- subnet: 172.28.0.0/16
# =========================================== # ===========================================
# Volumes # Volumes
@@ -260,13 +187,7 @@ volumes:
driver: local driver: local
logs_data: logs_data:
driver: local driver: local
nginx_cache: redis_data:
driver: local driver: local
ollama_data: ollama_data:
driver: local driver: local
redis_data:
driver: local
prometheus_data:
driver: local
grafana_data:
driver: local

View File

@@ -26,11 +26,15 @@ FROM python:3.12-slim AS production
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \ libmagic1 \
libpq5 \ libpq5 \
curl \ curl \
fonts-noto \
fonts-noto-cjk \
fonts-noto-cjk-extra \
libreoffice-writer-nogui \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& apt-get clean && 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 COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" ENV PATH="/opt/venv/bin:$PATH"
# Create non-root user for security # Create non-root user with a proper home directory (LibreOffice needs it)
RUN groupadd -r translator && useradd -r -g translator translator 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 # Create necessary directories
RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \ RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \

View File

@@ -6,19 +6,28 @@ echo "🚀 Starting Document Translation API..."
# Wait for database to be ready (if DATABASE_URL is set) # Wait for database to be ready (if DATABASE_URL is set)
if [ -n "$DATABASE_URL" ]; then if [ -n "$DATABASE_URL" ]; then
echo "⏳ Waiting for database to be ready..." echo "⏳ Waiting for database to be ready..."
# Extract host and port from DATABASE_URL # Extract host and port from DATABASE_URL (handles postgresql+asyncpg:// and postgresql://)
# postgresql://user:pass@host:port/db DB_HOST=$(python -c "
DB_HOST=$(echo $DATABASE_URL | sed -e 's/.*@\([^:]*\):.*/\1/') import re
DB_PORT=$(echo $DATABASE_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/') 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 # Wait up to 30 seconds for database
for i in {1..30}; do for i in $(seq 1 30); do
if python -c " if python -c "
import socket import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try: try:
s.connect(('$DB_HOST', $DB_PORT)) s.connect(('$DB_HOST', int('$DB_PORT')))
s.close() s.close()
exit(0) exit(0)
except: except:
@@ -30,7 +39,7 @@ except:
echo " Waiting for database... ($i/30)" echo " Waiting for database... ($i/30)"
sleep 1 sleep 1
done done
# Run database migrations # Run database migrations
echo "📦 Running database migrations..." echo "📦 Running database migrations..."
alembic upgrade head || echo "⚠️ Migration skipped (may already be up to date)" alembic upgrade head || echo "⚠️ Migration skipped (may already be up to date)"
@@ -39,15 +48,23 @@ fi
# Wait for Redis if configured # Wait for Redis if configured
if [ -n "$REDIS_URL" ]; then if [ -n "$REDIS_URL" ]; then
echo "⏳ Waiting for Redis..." echo "⏳ Waiting for Redis..."
REDIS_HOST=$(echo $REDIS_URL | sed -e 's/redis:\/\/\([^:]*\):.*/\1/') REDIS_HOST=$(python -c "
REDIS_PORT=$(echo $REDIS_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/') import re
m = re.search(r'://([^:/]+)', '$REDIS_URL')
for i in {1..10}; do 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 " if python -c "
import socket import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try: try:
s.connect(('$REDIS_HOST', $REDIS_PORT)) s.connect(('$REDIS_HOST', int('$REDIS_PORT')))
s.close() s.close()
exit(0) exit(0)
except: except:

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

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

View 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

View 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

View File

@@ -1,18 +1,11 @@
# Document Translation API - Main Server Block # Wordly.art - Production Nginx Config
# HTTP to HTTPS redirect and main application routing # HTTP to HTTPS redirect + main application routing
# HTTP server - redirect to HTTPS # HTTP server - redirect to HTTPS + Let's Encrypt
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name _; server_name wordly.art www.wordly.art;
# Allow health checks on HTTP
location /health {
proxy_pass http://backend/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# ACME challenge for Let's Encrypt # ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
@@ -21,7 +14,7 @@ server {
# Redirect all other traffic to HTTPS # Redirect all other traffic to HTTPS
location / { location / {
return 301 https://$host$request_uri; return 301 https://wordly.art$request_uri;
} }
} }
@@ -29,19 +22,16 @@ server {
server { server {
listen 443 ssl http2; listen 443 ssl http2;
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 /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.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_timeout 1d;
ssl_session_cache shared:SSL:50m; ssl_session_cache shared:SSL:50m;
ssl_session_tickets off; ssl_session_tickets off;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3; 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_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; ssl_prefer_server_ciphers off;
@@ -58,9 +48,16 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" 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/ { location /api/ {
limit_req zone=api_limit burst=20 nodelay; limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10; 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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection ""; 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-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" 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-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 { location /translate {
limit_req zone=upload_limit burst=5 nodelay; limit_req zone=upload_limit burst=5 nodelay;
limit_conn conn_limit 5; limit_conn conn_limit 5;
@@ -95,21 +91,51 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Increased timeouts for file processing
proxy_connect_timeout 60s; proxy_connect_timeout 60s;
proxy_send_timeout 600s; proxy_send_timeout 600s;
proxy_read_timeout 600s; proxy_read_timeout 600s;
} }
# Health check endpoint # Health check
location /health { location /health {
proxy_pass http://backend/health; proxy_pass http://backend/health;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; 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 { location /admin {
proxy_pass http://frontend; proxy_pass http://frontend;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -119,7 +145,13 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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 / { location / {
proxy_pass http://frontend; proxy_pass http://frontend;
proxy_http_version 1.1; proxy_http_version 1.1;
@@ -131,16 +163,21 @@ server {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
# Static files caching
location /_next/static/ {
proxy_pass http://frontend;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Error pages # Error pages
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/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;
}

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

View File

@@ -1,37 +1,34 @@
# Prometheus Configuration for Document Translation API # Wordly.art - Prometheus Configuration
global: global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
external_labels: external_labels:
monitor: 'translate-api' monitor: 'wordly-homelab'
environment: 'production'
alerting: rule_files:
alertmanagers: - 'alerts.yml'
- static_configs:
- targets: []
rule_files: []
scrape_configs: scrape_configs:
# Backend API metrics # Backend FastAPI
- job_name: 'translate-backend' - job_name: 'wordly-backend'
static_configs: static_configs:
- targets: ['backend:8000'] - targets: ['backend:8000']
metrics_path: /metrics metrics_path: /metrics
scrape_interval: 10s scrape_interval: 10s
# Nginx metrics (requires nginx-prometheus-exporter) # Systeme (CPU, RAM, Disk, Reseau)
- job_name: 'nginx'
static_configs:
- targets: ['nginx-exporter:9113']
# Node exporter for system metrics
- job_name: 'node' - job_name: 'node'
static_configs: static_configs:
- targets: ['node-exporter:9100'] - targets: ['node-exporter:9100']
# Docker metrics # Containers Docker
- job_name: 'docker' - job_name: 'docker'
static_configs: static_configs:
- targets: ['cadvisor:8080'] - targets: ['cadvisor:8080']
# Prometheus lui-meme
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']

View File

@@ -172,6 +172,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -534,6 +535,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@@ -574,6 +576,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
@@ -4591,7 +4594,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -4612,7 +4614,6 @@
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -4688,8 +4689,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -4781,6 +4781,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -4791,6 +4792,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -4801,6 +4803,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -4851,6 +4854,7 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0", "@typescript-eslint/types": "8.48.0",
@@ -5495,6 +5499,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -5638,7 +5643,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -6072,6 +6076,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6630,7 +6635,6 @@
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -6668,8 +6672,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
@@ -7008,6 +7011,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -7193,6 +7197,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -7490,6 +7495,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -8142,6 +8148,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -8863,6 +8870,7 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.31", "@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1", "@asamuzakjp/dom-selector": "^6.8.1",
@@ -9345,7 +9353,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -10191,7 +10198,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -10207,7 +10213,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -10220,8 +10225,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
@@ -10356,6 +10360,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -10365,6 +10370,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -11501,6 +11507,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11764,6 +11771,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -12011,6 +12019,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -12104,6 +12113,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -12468,6 +12478,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -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>
</>
)
}

View File

@@ -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>
);
}

View File

@@ -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&#10;récupérateur=heat recovery unit&#10;ventilo-connecteur=fan coil unit&#10;gaine=duct&#10;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -11,11 +11,13 @@ import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminLogin } from "./login/useAdminLogin"; import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants"; import { adminNavItems } from "./constants";
import { useI18n } from "@/lib/i18n";
export function AdminHeader() { export function AdminHeader() {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const { logout } = useAdminLogin(); const { logout } = useAdminLogin();
const { t } = useI18n();
return ( return (
<> <>
@@ -38,9 +40,9 @@ export function AdminHeader() {
</div> </div>
<div className="hidden items-center gap-2 lg:flex"> <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" /> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -48,7 +50,7 @@ export function AdminHeader() {
variant="outline" variant="outline"
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0" 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 Superadmin
</Badge> </Badge>
<Avatar className="size-6"> <Avatar className="size-6">
@@ -66,7 +68,7 @@ export function AdminHeader() {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
<Link <Link
key={item.label} key={item.href}
href={item.href} href={item.href}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={cn( className={cn(
@@ -77,7 +79,7 @@ export function AdminHeader() {
)} )}
> >
<item.icon className="size-3.5 shrink-0" /> <item.icon className="size-3.5 shrink-0" />
{item.label} {t(item.labelKey)}
</Link> </Link>
); );
})} })}

View File

@@ -9,10 +9,12 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAdminLogin } from "./login/useAdminLogin"; import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants"; import { adminNavItems } from "./constants";
import { useI18n } from "@/lib/i18n";
export function AdminSidebar() { export function AdminSidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { logout } = useAdminLogin(); const { logout } = useAdminLogin();
const { t } = useI18n();
return ( return (
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col"> <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; const isActive = pathname === item.href;
return ( return (
<Link <Link
key={item.label} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors", "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.icon className="size-3.5 shrink-0" />
{item.label} {t(item.labelKey)}
</Link> </Link>
); );
})} })}

View File

@@ -4,6 +4,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react"; import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
import { useI18n } from "@/lib/i18n";
import type { AdminDashboardData } from "./types"; import type { AdminDashboardData } from "./types";
interface SystemHealthCardsProps { interface SystemHealthCardsProps {
@@ -21,6 +22,7 @@ export function SystemHealthCards({
onPurge, onPurge,
purgeResult, purgeResult,
}: SystemHealthCardsProps) { }: SystemHealthCardsProps) {
const { t } = useI18n();
const diskUsed = data?.system?.disk?.used_percent ?? 0; const diskUsed = data?.system?.disk?.used_percent ?? 0;
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0; const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
const systemStatus = data?.status ?? "unhealthy"; const systemStatus = data?.status ?? "unhealthy";
@@ -82,14 +84,14 @@ export function SystemHealthCards({
</span> </span>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
{systemStatus === "healthy" {systemStatus === "healthy"
? "All Systems Operational" ? t('admin.system.allOperational')
: "System Issues Detected"} : t('admin.system.issuesDetected')}
</span> </span>
</div> </div>
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{data?.timestamp {data?.timestamp
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}` ? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
: "Waiting for data..."} : t('admin.system.waitingData')}
</span> </span>
</div> </div>
</CardContent> </CardContent>
@@ -151,10 +153,10 @@ export function SystemHealthCards({
<Trash2 className="size-3" /> <Trash2 className="size-3" />
)} )}
{isPurging {isPurging
? "Purging..." ? t('admin.system.purging')
: trackedFilesCount === 0 : trackedFilesCount === 0
? "Clean" ? t('admin.system.clean')
: "Purge"} : t('admin.system.purge')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -112,7 +112,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
<TableRow> <TableRow>
<TableHead className="w-16">Rang</TableHead> <TableHead className="w-16">Rang</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead className="text-right">Traductions</TableHead> <TableHead className="text-end">Traductions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -120,7 +120,7 @@ export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
<TableRow key={user.user_id}> <TableRow key={user.user_id}>
<TableCell>{getRankBadge(index + 1)}</TableCell> <TableCell>{getRankBadge(index + 1)}</TableCell>
<TableCell className="font-medium">{user.email}</TableCell> <TableCell className="font-medium">{user.email}</TableCell>
<TableCell className="text-right"> <TableCell className="text-end">
<Badge variant="secondary">{user.translation_count}</Badge> <Badge variant="secondary">{user.translation_count}</Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -1,16 +1,16 @@
import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react'; import { CreditCard, LayoutDashboard, Settings, FileText, Users, type LucideIcon } from 'lucide-react';
export interface AdminNavItem { export interface AdminNavItem {
label: string; labelKey: string;
href: string; href: string;
icon: LucideIcon; icon: LucideIcon;
} }
export const adminNavItems: AdminNavItem[] = [ export const adminNavItems: AdminNavItem[] = [
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard }, { labelKey: 'admin.nav.dashboard', href: '/admin', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users }, { labelKey: 'admin.nav.users', href: '/admin/users', icon: Users },
{ label: 'Pricing & Stripe', href: '/admin/pricing', icon: CreditCard }, { labelKey: 'admin.nav.pricing', href: '/admin/pricing', icon: CreditCard },
{ label: 'Providers', href: '/admin/settings', icon: Settings }, { labelKey: 'admin.nav.providers', href: '/admin/settings', icon: Settings },
{ label: 'System', href: '/admin/system', icon: Settings }, { labelKey: 'admin.nav.system', href: '/admin/system', icon: Settings },
{ label: 'Logs', href: '/admin/logs', icon: FileText }, { labelKey: 'admin.nav.logs', href: '/admin/logs', icon: FileText },
]; ];

View File

@@ -6,6 +6,7 @@ import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config"; import { API_BASE } from "@/lib/config";
import { AdminSidebar } from "./AdminSidebar"; import { AdminSidebar } from "./AdminSidebar";
import { AdminHeader } from "./AdminHeader"; import { AdminHeader } from "./AdminHeader";
import { useI18n } from "@/lib/i18n";
export default function AdminLayout({ export default function AdminLayout({
children, children,
@@ -15,9 +16,9 @@ export default function AdminLayout({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { settings, setAdminToken } = useTranslationStore(); const { settings, setAdminToken } = useTranslationStore();
const { t } = useI18n();
const [isChecking, setIsChecking] = useState(true); const [isChecking, setIsChecking] = useState(true);
const [isValid, setIsValid] = useState(false); const [isValid, setIsValid] = useState(false);
/** Sans ça, au premier rendu après un chargement complet le token nest pas encore lu depuis localStorage → fausse absence de token → redirection /admin/login (bug sur F5 ou URL directe). */
const [persistHydrated, setPersistHydrated] = useState(false); const [persistHydrated, setPersistHydrated] = useState(false);
useEffect(() => { useEffect(() => {
@@ -75,7 +76,7 @@ export default function AdminLayout({
if (isChecking && pathname !== "/admin/login") { if (isChecking && pathname !== "/admin/login") {
return ( return (
<div className="min-h-screen bg-card flex items-center justify-center"> <div className="min-h-screen bg-card flex items-center justify-center">
<div className="text-muted-foreground text-sm">Vérification de l&apos;authentification...</div> <div className="text-muted-foreground text-sm">{t('admin.layout.checking')}</div>
</div> </div>
); );
} }

View File

@@ -4,11 +4,13 @@ import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAdminLogin } from "./useAdminLogin"; import { useAdminLogin } from "./useAdminLogin";
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react"; import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
import { useI18n } from "@/lib/i18n";
function AdminLoginContent() { function AdminLoginContent() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { login, isLoading, error } = useAdminLogin(); const { login, isLoading, error } = useAdminLogin();
const { t } = useI18n();
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); 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"> <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" /> <Shield className="w-8 h-8 text-purple-400" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Administration</h1> <h1 className="text-2xl font-bold text-white">{t('admin.login.title')}</h1>
<p className="text-gray-400 mt-2">Connexion requise</p> <p className="text-gray-400 mt-2">{t('admin.login.required')}</p>
</div> </div>
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8"> <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 className="space-y-6">
<div> <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"> <div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> <Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
@@ -47,7 +49,7 @@ function AdminLoginContent() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" 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 required
disabled={isLoading} disabled={isLoading}
/> />
@@ -69,12 +71,12 @@ function AdminLoginContent() {
{isLoading ? ( {isLoading ? (
<> <>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <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" /> <Shield className="w-5 h-5" />
Accéder au panneau admin {t('admin.login.access')}
</> </>
)} )}
</button> </button>
@@ -82,7 +84,7 @@ function AdminLoginContent() {
</form> </form>
<p className="text-center text-gray-500 text-sm mt-6"> <p className="text-center text-gray-500 text-sm mt-6">
Accès réservé aux administrateurs {t('admin.login.restricted')}
</p> </p>
</div> </div>
</div> </div>
@@ -90,10 +92,11 @@ function AdminLoginContent() {
} }
export default function AdminLoginPage() { export default function AdminLoginPage() {
const { t } = useI18n();
return ( return (
<Suspense fallback={ <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="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> </div>
}> }>
<AdminLoginContent /> <AdminLoginContent />

View File

@@ -6,6 +6,7 @@ import { SystemHealthCards } from "./SystemHealthCards";
import { ProviderStatus } from "./ProviderStatus"; import { ProviderStatus } from "./ProviderStatus";
import { useAdminDashboard } from "./useAdminDashboard"; import { useAdminDashboard } from "./useAdminDashboard";
import { useCleanup } from "./useCleanup"; import { useCleanup } from "./useCleanup";
import { useI18n } from "@/lib/i18n";
import { import {
TooltipProvider, TooltipProvider,
Tooltip, Tooltip,
@@ -16,6 +17,7 @@ import {
export default function AdminPage() { export default function AdminPage() {
const { data, isLoading, error, refetch } = useAdminDashboard(); const { data, isLoading, error, refetch } = useAdminDashboard();
const { isPurging, purgeResult, triggerCleanup } = useCleanup(); const { isPurging, purgeResult, triggerCleanup } = useCleanup();
const { t } = useI18n();
const handlePurge = async () => { const handlePurge = async () => {
await triggerCleanup(); await triggerCleanup();
@@ -32,10 +34,10 @@ export default function AdminPage() {
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold text-foreground"> <h1 className="text-xl font-semibold text-foreground">
Dashboard Admin {t('admin.dashboard.title')}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Panneau de contrôle administrateur {t('admin.dashboard.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -54,11 +56,11 @@ export default function AdminPage() {
) : ( ) : (
<RefreshCw className="size-3.5" /> <RefreshCw className="size-3.5" />
)} )}
Refresh {t('admin.dashboard.refresh')}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Refresh dashboard data</p> <p>{t('admin.dashboard.refreshTooltip')}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
@@ -83,23 +85,23 @@ export default function AdminPage() {
{data?.config && ( {data?.config && (
<div className="rounded-lg border border-border bg-card px-4 py-3"> <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"> <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
System Configuration {t('admin.dashboard.config')}
</span> </span>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span> <span>
Max file size:{" "} {t('admin.dashboard.maxFileSize')}{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{data.config.max_file_size_mb}MB {data.config.max_file_size_mb}MB
</strong> </strong>
</span> </span>
<span> <span>
Translation service:{" "} {t('admin.dashboard.translationService')}{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{data.config.translation_service} {data.config.translation_service}
</strong> </strong>
</span> </span>
<span> <span>
Formats:{" "} {t('admin.dashboard.formats')}{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{data.config.supported_extensions.join(", ")} {data.config.supported_extensions.join(", ")}
</strong> </strong>

View File

@@ -455,7 +455,7 @@ export default function AdminSettingsPage() {
) : ( ) : (
<RefreshCw className="size-3" /> <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> </Button>
</div> </div>
{ollamaModels.length > 0 ? ( {ollamaModels.length > 0 ? (
@@ -471,7 +471,7 @@ export default function AdminSettingsPage() {
<SelectItem key={model.name} value={model.name}> <SelectItem key={model.name} value={model.name}>
{model.name} {model.name}
{model.size > 0 && ( {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) ({(model.size / 1e9).toFixed(1)} GB)
</span> </span>
)} )}
@@ -660,13 +660,13 @@ export default function AdminSettingsPage() {
className="h-8" className="h-8"
> >
{testResults.smtp === "testing" ? ( {testResults.smtp === "testing" ? (
<><Loader2 className="size-3 animate-spin mr-1" />Test...</> <><Loader2 className="size-3 animate-spin me-1" />Test...</>
) : testResults.smtp === "ok" ? ( ) : 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" ? ( ) : 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> </Button>
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} /> <Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
@@ -752,13 +752,13 @@ export default function AdminSettingsPage() {
className="h-8" className="h-8"
> >
{isSendingTestEmail ? ( {isSendingTestEmail ? (
<><Loader2 className="size-3 animate-spin mr-1" />Envoi...</> <><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
) : testEmailResult === "ok" ? ( ) : testEmailResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />Envoyé</> <><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
) : testEmailResult === "error" ? ( ) : 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> </Button>
{testEmailMessage && ( {testEmailMessage && (
@@ -775,12 +775,12 @@ export default function AdminSettingsPage() {
<Button onClick={saveConfig} disabled={isSaving} size="lg"> <Button onClick={saveConfig} disabled={isSaving} size="lg">
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="mr-2 size-4 animate-spin" /> <Loader2 className="me-2 size-4 animate-spin" />
Sauvegarde... Sauvegarde...
</> </>
) : ( ) : (
<> <>
<Save className="mr-2 size-4" /> <Save className="me-2 size-4" />
Sauvegarder la configuration Sauvegarder la configuration
</> </>
)} )}
@@ -838,13 +838,13 @@ function ProviderCard({
className="h-8" className="h-8"
> >
{testResult === "testing" ? ( {testResult === "testing" ? (
<><Loader2 className="size-3 animate-spin mr-1" />Test...</> <><Loader2 className="size-3 animate-spin me-1" />Test...</>
) : testResult === "ok" ? ( ) : testResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</> <><CheckCircle className="size-3 text-green-500 me-1" />OK</>
) : testResult === "error" ? ( ) : 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> </Button>
<Switch checked={enabled} onCheckedChange={onToggle} /> <Switch checked={enabled} onCheckedChange={onToggle} />

View File

@@ -1,13 +1,21 @@
"use client"; "use client";
import { Settings, AlertCircle, Loader2 } from "lucide-react"; import { Settings, AlertCircle, Loader2, RotateCcw, CheckCircle2 } from "lucide-react";
import { useSystemPage } from "./useSystemPage"; import { useSystemPage } from "./useSystemPage";
import { CleanupSection } from "./CleanupSection"; import { CleanupSection } from "./CleanupSection";
import { DiskSpaceCard } from "./DiskSpaceCard"; import { DiskSpaceCard } from "./DiskSpaceCard";
import { ProviderStatus } from "../ProviderStatus"; 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() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -16,9 +24,9 @@ export default function AdminSystemPage() {
<Settings className="w-5 h-5 text-purple-400" /> <Settings className="w-5 h-5 text-purple-400" />
</div> </div>
<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"> <p className="text-sm text-muted-foreground">
Surveiller l'état du système et gérer les ressources {t('admin.system.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -32,7 +40,7 @@ export default function AdminSystemPage() {
{isLoading && !data ? ( {isLoading && !data ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{[1, 2].map((i) => ( {[1, 2, 3].map((i) => (
<div <div
key={i} key={i}
className="h-[88px] animate-pulse rounded-lg border border-border bg-card" className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
@@ -52,6 +60,43 @@ export default function AdminSystemPage() {
purgeResult={purgeResult} purgeResult={purgeResult}
onCleanup={handleCleanup} 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> </div>
)} )}

View File

@@ -1,14 +1,39 @@
"use client"; "use client";
import { useState } from "react";
import { useAdminDashboard } from "../useAdminDashboard"; import { useAdminDashboard } from "../useAdminDashboard";
import { useCleanup } from "../useCleanup"; import { useCleanup } from "../useCleanup";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
export function useSystemPage() { export function useSystemPage() {
const { data, isLoading, error } = useAdminDashboard(); const { data, isLoading, error } = useAdminDashboard();
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup(); 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 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 { return {
data, data,
isLoading, isLoading,
@@ -16,5 +41,8 @@ export function useSystemPage() {
isPurging, isPurging,
purgeResult, purgeResult,
handleCleanup, handleCleanup,
isResetting,
resetResult,
handleResetQuotas,
}; };
} }

View File

@@ -83,7 +83,7 @@ const statusConfig: Record<string, { label: string; dotClass: string; textClass:
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
try { try {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", { return date.toLocaleDateString(undefined, {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
@@ -208,7 +208,7 @@ export function UserTable({
placeholder="Rechercher par email..." placeholder="Rechercher par email..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs" className="h-8 ps-8 text-xs"
/> />
</div> </div>
</div> </div>
@@ -220,7 +220,7 @@ export function UserTable({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <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 Email
</TableHead> </TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> <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"> <TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Clés Clés
</TableHead> </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 Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -255,7 +255,7 @@ export function UserTable({
return ( return (
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}> <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"> <div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground"> <span className="text-xs font-medium text-foreground">
{user.email} {user.email}
@@ -357,7 +357,7 @@ export function UserTable({
</span> </span>
</TableCell> </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"> <div className="flex justify-end gap-1.5">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@@ -8,6 +8,7 @@ import { useRevokeApiKey } from "./useRevokeApiKey";
import { UserStats } from "./UserStats"; import { UserStats } from "./UserStats";
import { UserTable } from "./UserTable"; import { UserTable } from "./UserTable";
import { useToast } from "@/components/ui/toast"; import { useToast } from "@/components/ui/toast";
import { useI18n } from "@/lib/i18n";
import type { PlanType } from "./types"; import type { PlanType } from "./types";
export default function AdminUsersPage() { export default function AdminUsersPage() {
@@ -16,19 +17,20 @@ export default function AdminUsersPage() {
const { revokeKey, isRevoking } = useRevokeApiKey(); const { revokeKey, isRevoking } = useRevokeApiKey();
const { resetPassword, isResetting } = useAdminResetPassword(); const { resetPassword, isResetting } = useAdminResetPassword();
const toast = useToast(); const toast = useToast();
const { t } = useI18n();
const handleTierChange = async (userId: string, plan: PlanType) => { const handleTierChange = async (userId: string, plan: PlanType) => {
try { try {
await updateTier({ userId, plan }); await updateTier({ userId, plan });
toast.success({ toast.success({
title: "Plan mis à jour", title: t('admin.users.planUpdated'),
description: `Le plan a été changé vers "${plan}" avec succès.`, description: t('admin.users.planChanged', { plan }),
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue"; const message = err instanceof Error ? err.message : t('admin.users.unknownError');
toast.error({ toast.error({
title: "Erreur", title: t('admin.users.error'),
description: `Impossible de mettre à jour le plan: ${message}`, description: t('admin.users.planUpdateError', { message }),
}); });
throw err; throw err;
} }
@@ -37,8 +39,8 @@ export default function AdminUsersPage() {
const handleRevokeKeys = async (userId: string, keyIds: string[]) => { const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
if (!keyIds || keyIds.length === 0) { if (!keyIds || keyIds.length === 0) {
toast.warning({ toast.warning({
title: "Aucune clé", title: t('admin.users.noKeys'),
description: "Cet utilisateur n'a pas de clés API actives.", description: t('admin.users.noKeysDesc'),
}); });
return; return;
} }
@@ -50,15 +52,15 @@ export default function AdminUsersPage() {
) )
); );
toast.success({ toast.success({
title: "Clés révoquées", title: t('admin.users.keysRevoked'),
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.`, description: t('admin.users.keysRevokedDesc', { count: keyIds.length }),
}); });
refetch(); refetch();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue"; const message = err instanceof Error ? err.message : t('admin.users.unknownError');
toast.error({ toast.error({
title: "Erreur", title: t('admin.users.error'),
description: `Impossible de révoquer les clés: ${message}`, description: t('admin.users.revokeError', { message }),
}); });
throw err; throw err;
} }
@@ -72,8 +74,8 @@ export default function AdminUsersPage() {
<Users className="w-5 h-5 text-blue-400" /> <Users className="w-5 h-5 text-blue-400" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1> <h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p> <p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
</div> </div>
</div> </div>
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4"> <div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
@@ -82,7 +84,7 @@ export default function AdminUsersPage() {
onClick={() => refetch()} onClick={() => refetch()}
className="mt-2 text-xs text-destructive hover:underline" className="mt-2 text-xs text-destructive hover:underline"
> >
Réessayer {t('admin.users.retry')}
</button> </button>
</div> </div>
</div> </div>
@@ -96,8 +98,8 @@ export default function AdminUsersPage() {
<Users className="w-5 h-5 text-blue-400" /> <Users className="w-5 h-5 text-blue-400" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1> <h1 className="text-xl font-semibold text-foreground">{t('admin.users.title')}</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p> <p className="text-sm text-muted-foreground">{t('admin.users.subtitle')}</p>
</div> </div>
</div> </div>

View File

@@ -8,19 +8,21 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiClient } from '@/lib/apiClient'; import { apiClient } from '@/lib/apiClient';
import { useI18n } from '@/lib/i18n';
function ForgotPasswordForm() { function ForgotPasswordForm() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false); const [sent, setSent] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const { t } = useI18n();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!email) { if (!email) {
setError('Veuillez entrer votre adresse email'); setError(t('forgotPassword.enterEmail'));
return; return;
} }
@@ -29,7 +31,7 @@ function ForgotPasswordForm() {
await apiClient.post('/api/v1/auth/forgot-password', { email }); await apiClient.post('/api/v1/auth/forgot-password', { email });
setSent(true); setSent(true);
} catch (err: unknown) { } 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); setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -44,15 +46,15 @@ function ForgotPasswordForm() {
<Languages className="h-6 w-6" /> <Languages className="h-6 w-6" />
</div> </div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300"> <span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Office Translator {t('auth.brandName')}
</span> </span>
</Link> </Link>
<CardTitle className="text-2xl font-bold">Mot de passe oublie</CardTitle> <CardTitle className="text-2xl font-bold">{t('forgotPassword.title')}</CardTitle>
<CardDescription> <CardDescription>
{sent {sent
? 'Verifiez votre boite mail' ? t('forgotPassword.checkEmail')
: 'Entrez votre email pour recevoir un lien de reinitialisation'} : t('forgotPassword.subtitle')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -62,7 +64,7 @@ function ForgotPasswordForm() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" /> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -75,11 +77,11 @@ function ForgotPasswordForm() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Adresse email</Label> <Label htmlFor="email">{t('forgotPassword.emailLabel')}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="vous@exemple.com" placeholder={t('forgotPassword.emailPlaceholder')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
leftIcon={<Mail className="h-4 w-4" />} leftIcon={<Mail className="h-4 w-4" />}
@@ -98,11 +100,11 @@ function ForgotPasswordForm() {
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
Envoi en cours... {t('forgotPassword.sending')}
</> </>
) : ( ) : (
'Envoyer le lien de reinitialisation' t('forgotPassword.sendLink')
)} )}
</Button> </Button>
</form> </form>
@@ -111,7 +113,7 @@ function ForgotPasswordForm() {
<p className="text-center text-sm text-muted-foreground"> <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"> <Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
Retour a la connexion {t('forgotPassword.backToLogin')}
</Link> </Link>
</p> </p>
</CardContent> </CardContent>
@@ -120,6 +122,7 @@ function ForgotPasswordForm() {
} }
function LoadingFallback() { function LoadingFallback() {
const { t } = useI18n();
return ( return (
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8"> <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" /> <Languages className="h-6 w-6" />
</div> </div>
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <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> </div>
</div> </div>

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNotification } from '@/components/ui/notification'; import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n';
import { useLogin } from './useLogin'; import { useLogin } from './useLogin';
export function LoginForm() { export function LoginForm() {
@@ -17,11 +18,12 @@ export function LoginForm() {
const loginMutation = useLogin(); const loginMutation = useLogin();
const { notify } = useNotification(); const { notify } = useNotification();
const { t } = useI18n();
useEffect(() => { useEffect(() => {
if (loginMutation.isError && loginMutation.error) { if (loginMutation.isError && loginMutation.error) {
notify({ notify({
title: 'Erreur de connexion', title: t('login.errorTitle'),
description: loginMutation.error.message, description: loginMutation.error.message,
variant: 'destructive', variant: 'destructive',
}); });
@@ -41,26 +43,26 @@ export function LoginForm() {
<Languages className="h-6 w-6" /> <Languages className="h-6 w-6" />
</div> </div>
<span className="text-2xl font-semibold text-foreground"> <span className="text-2xl font-semibold text-foreground">
Office Translator {t('auth.brandName')}
</span> </span>
</Link> </Link>
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">
Welcome back {t('login.welcomeBack')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Sign in to continue translating {t('login.signInToContinue')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">{t('login.email')}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="you@example.com" placeholder={t('login.emailPlaceholder')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
leftIcon={<Mail className="h-4 w-4" />} leftIcon={<Mail className="h-4 w-4" />}
@@ -70,16 +72,16 @@ export function LoginForm() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between items-center"> <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"> <Link href="/auth/forgot-password" className="text-sm text-primary hover:underline">
Forgot password? {t('login.forgotPassword')}
</Link> </Link>
</div> </div>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
placeholder="••••••••" placeholder={t('login.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
leftIcon={<Lock className="h-4 w-4" />} leftIcon={<Lock className="h-4 w-4" />}
@@ -102,22 +104,22 @@ export function LoginForm() {
> >
{loginMutation.isPending ? ( {loginMutation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
Signing in... {t('login.signingIn')}
</> </>
) : ( ) : (
<> <>
Sign In {t('login.signIn')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ms-2 h-4 w-4" />
</> </>
)} )}
</Button> </Button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '} {t('login.noAccount')}{' '}
<Link href="/auth/register" className="text-primary hover:underline"> <Link href="/auth/register" className="text-primary hover:underline">
Sign up for free {t('login.signUpFree')}
</Link> </Link>
</p> </p>
</CardContent> </CardContent>

View File

@@ -1,8 +1,12 @@
'use client';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { LoginForm } from './LoginForm'; import { LoginForm } from './LoginForm';
import { Loader2, Languages } from 'lucide-react'; import { Loader2, Languages } from 'lucide-react';
import { useI18n } from '@/lib/i18n';
function LoadingFallback() { function LoadingFallback() {
const { t } = useI18n();
return ( return (
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8"> <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" /> <Languages className="h-6 w-6" />
</div> </div>
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <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> </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-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" /> <div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div> </div>
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <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 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 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 className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-pulse" />
</div> </div>
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LoginForm /> <LoginForm />
</Suspense> </Suspense>

View File

@@ -12,7 +12,7 @@ export function useLogin() {
return useMutation<LoginResponse, ApiClientError, LoginRequest>({ return useMutation<LoginResponse, ApiClientError, LoginRequest>({
mutationFn: async (credentials: 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', '/api/v1/auth/login',
credentials credentials
); );

View File

@@ -2,6 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/lib/i18n';
import { import {
Eye, Eye,
EyeOff, EyeOff,
@@ -34,7 +35,7 @@ function validatePassword(password: string) {
} }
function getPasswordStrength(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; let score = 0;
if (password.length >= 8) score++; if (password.length >= 8) score++;
if (password.length >= 12) score++; if (password.length >= 12) score++;
@@ -43,9 +44,9 @@ function getPasswordStrength(password: string) {
if (/[0-9]/.test(password)) score++; if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++; if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return { score, label: 'Faible', color: 'bg-destructive' }; if (score <= 2) return { score, labelKey: 'register.password.strength.weak', color: 'bg-destructive' };
if (score <= 4) return { score, label: 'Moyen', color: 'bg-yellow-500' }; if (score <= 4) return { score, labelKey: 'register.password.strength.medium', color: 'bg-yellow-500' };
return { score, label: 'Fort', color: 'bg-green-500' }; return { score, labelKey: 'register.password.strength.strong', color: 'bg-green-500' };
} }
function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; onToggle: () => void; label: string }) { 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 [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
const registerMutation = useRegister(); const registerMutation = useRegister();
const { t } = useI18n();
const nameError = touched.name && name.length > 0 && name.length < 2 const nameError = touched.name && name.length > 0 && name.length < 2
? 'Le nom doit contenir au moins 2 caractères' ? t('register.name.error')
: undefined; : undefined;
const emailError = touched.email && email.length > 0 && !validateEmail(email) const emailError = touched.email && email.length > 0 && !validateEmail(email)
? 'Adresse email invalide' ? t('register.email.error')
: undefined; : undefined;
const passwordError = touched.password && password.length > 0 && !validatePassword(password) 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; : undefined;
const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword
? 'Les mots de passe ne correspondent pas' ? t('register.confirmPassword.error')
: undefined; : undefined;
const passwordStrength = getPasswordStrength(password); const passwordStrength = getPasswordStrength(password);
@@ -112,7 +114,7 @@ export function RegisterForm() {
<PasswordToggleIcon <PasswordToggleIcon
visible={showConfirm} visible={showConfirm}
onToggle={() => setShowConfirm(!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> </span>
</Link> </Link>
<CardTitle className="text-2xl font-bold">Créer un compte</CardTitle> <CardTitle className="text-2xl font-bold">{t('register.title')}</CardTitle>
<CardDescription>Commencez à traduire gratuitement</CardDescription> <CardDescription>{t('register.subtitle')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
@@ -139,7 +141,7 @@ export function RegisterForm() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
{registerMutation.error?.message || "L'inscription a échoué"} {registerMutation.error?.message || t('register.error.failed')}
</p> </p>
</div> </div>
</div> </div>
@@ -147,11 +149,11 @@ export function RegisterForm() {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Nom</Label> <Label htmlFor="name">{t('register.name.label')}</Label>
<Input <Input
id="name" id="name"
type="text" type="text"
placeholder="Votre nom" placeholder={t('register.name.placeholder')}
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, name: true }))} onBlur={() => setTouched((t) => ({ ...t, name: true }))}
@@ -170,11 +172,11 @@ export function RegisterForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Adresse email</Label> <Label htmlFor="email">{t('register.email.label')}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="vous@exemple.com" placeholder={t('register.email.placeholder')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, email: true }))} onBlur={() => setTouched((t) => ({ ...t, email: true }))}
@@ -193,7 +195,7 @@ export function RegisterForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label> <Label htmlFor="password">{t('register.password.label')}</Label>
<Input <Input
id="password" id="password"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
@@ -206,7 +208,7 @@ export function RegisterForm() {
<PasswordToggleIcon <PasswordToggleIcon
visible={showPassword} visible={showPassword}
onToggle={() => setShowPassword(!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} error={passwordError}
@@ -230,14 +232,14 @@ export function RegisterForm() {
))} ))}
</div> </div>
<p className={cn('text-xs', passwordStrength.score <= 2 ? 'text-destructive' : passwordStrength.score <= 4 ? 'text-muted-foreground' : 'text-green-500')}> <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> </p>
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label> <Label htmlFor="confirmPassword">{t('register.confirmPassword.label')}</Label>
<Input <Input
id="confirmPassword" id="confirmPassword"
type={showConfirm ? 'text' : 'password'} type={showConfirm ? 'text' : 'password'}
@@ -263,30 +265,30 @@ export function RegisterForm() {
> >
{registerMutation.isPending ? ( {registerMutation.isPending ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
Création du compte... {t('register.submit.creating')}
</> </>
) : ( ) : (
<> <>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="me-2 h-4 w-4" />
Créer mon compte {t('register.submit.create')}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ms-2 h-4 w-4" />
</> </>
)} )}
</Button> </Button>
</form> </form>
<p className="text-center text-sm text-muted-foreground"> <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"> <Link href="/auth/login" className="text-primary hover:underline font-medium">
Se connecter {t('register.login')}
</Link> </Link>
</p> </p>
<p className="text-center text-xs text-muted-foreground"> <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"> <span className="text-muted-foreground">
utilisation du service {t('register.terms.link')}
</span> </span>
. .
</p> </p>

View File

@@ -1,8 +1,11 @@
import { Suspense } from 'react'; 'use client';
import { Languages, Loader2 } from 'lucide-react'; import { Languages, Loader2 } from 'lucide-react';
import { RegisterForm } from './RegisterForm'; import { RegisterForm } from './RegisterForm';
import { useI18n } from '@/lib/i18n';
function LoadingFallback() { function LoadingFallback() {
const { t } = useI18n();
return ( return (
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8"> <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" /> <Languages className="h-6 w-6" />
</div> </div>
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <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> </div>
</div> </div>
@@ -21,25 +24,23 @@ function LoadingFallback() {
export default function RegisterPage() { export default function RegisterPage() {
return ( 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"> <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 gradé */} {/* Background gradient */}
<div className="absolute inset-0"> <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-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-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" /> <div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div> </div>
{/* Éléments flottants décoratifs */} {/* Decorative floating elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <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-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 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 className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse" />
</div> </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"> <div className="relative z-10 w-full max-w-md">
<Suspense fallback={<LoadingFallback />}> <RegisterForm />
<RegisterForm />
</Suspense>
</div> </div>
</div> </div>
); );

View File

@@ -20,7 +20,7 @@ export function useRegister() {
mutationFn: async (data: RegisterRequest) => { mutationFn: async (data: RegisterRequest) => {
await apiClient.post<RegisterResponse>('/api/v1/auth/register', data); 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', '/api/v1/auth/login',
{ email: data.email, password: data.password } { email: data.email, password: data.password }
); );

View File

@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiClient } from '@/lib/apiClient'; import { apiClient } from '@/lib/apiClient';
import { useI18n } from '@/lib/i18n';
function validatePassword(password: string) { function validatePassword(password: string) {
return password.length >= 8 return password.length >= 8
@@ -21,6 +22,7 @@ function ResetPasswordForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const { t } = useI18n();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
@@ -31,11 +33,11 @@ function ResetPasswordForm() {
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const passwordError = password.length > 0 && !validatePassword(password) 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 const confirmError = confirmPassword.length > 0 && password !== confirmPassword
? 'Les mots de passe ne correspondent pas' ? t('resetPassword.passwordMismatch')
: ''; : '';
const isFormValid = validatePassword(password) && password === confirmPassword; const isFormValid = validatePassword(password) && password === confirmPassword;
@@ -45,7 +47,7 @@ function ResetPasswordForm() {
setError(''); setError('');
if (!token) { if (!token) {
setError('Token manquant. Veuillez utiliser le lien recu par email.'); setError(t('resetPassword.tokenMissing'));
return; return;
} }
@@ -59,7 +61,7 @@ function ResetPasswordForm() {
router.push('/auth/login'); router.push('/auth/login');
}, 3000); }, 3000);
} catch (err: unknown) { } 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); setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -75,20 +77,20 @@ function ResetPasswordForm() {
<Languages className="h-6 w-6" /> <Languages className="h-6 w-6" />
</div> </div>
<span className="text-2xl font-semibold text-foreground"> <span className="text-2xl font-semibold text-foreground">
Office Translator {t('auth.brandName')}
</span> </span>
</Link> </Link>
<CardTitle className="text-2xl font-bold">Lien invalide</CardTitle> <CardTitle className="text-2xl font-bold">{t('resetPassword.invalidLink')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"> <div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Ce lien de reinitialisation est invalide. Veuillez redemander un nouveau lien. {t('resetPassword.invalidLinkMessage')}
</p> </p>
</div> </div>
<p className="text-center text-sm text-muted-foreground"> <p className="text-center text-sm text-muted-foreground">
<Link href="/auth/forgot-password" className="text-primary hover:underline font-medium"> <Link href="/auth/forgot-password" className="text-primary hover:underline font-medium">
Redemander un lien {t('resetPassword.requestNewLink')}
</Link> </Link>
</p> </p>
</CardContent> </CardContent>
@@ -104,17 +106,17 @@ function ResetPasswordForm() {
<Languages className="h-6 w-6" /> <Languages className="h-6 w-6" />
</div> </div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300"> <span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Office Translator {t('auth.brandName')}
</span> </span>
</Link> </Link>
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">
{success ? 'Mot de passe reinitialise' : 'Nouveau mot de passe'} {success ? t('resetPassword.successTitle') : t('resetPassword.newPasswordTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{success {success
? 'Vous allez etre redirige vers la connexion' ? t('resetPassword.successSubtitle')
: 'Definissez votre nouveau mot de passe'} : t('resetPassword.subtitle')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -124,7 +126,7 @@ function ResetPasswordForm() {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" /> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -137,7 +139,7 @@ function ResetPasswordForm() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Nouveau mot de passe</Label> <Label htmlFor="password">{t('resetPassword.newPassword')}</Label>
<div className="relative"> <div className="relative">
<Input <Input
id="password" id="password"
@@ -156,12 +158,12 @@ function ResetPasswordForm() {
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{showPassword ? 'Masquer' : 'Afficher'} le mot de passe {showPassword ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
</button> </button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label> <Label htmlFor="confirmPassword">{t('resetPassword.confirmPassword')}</Label>
<div className="relative"> <div className="relative">
<Input <Input
id="confirmPassword" id="confirmPassword"
@@ -180,7 +182,7 @@ function ResetPasswordForm() {
onClick={() => setShowConfirm(!showConfirm)} onClick={() => setShowConfirm(!showConfirm)}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
{showConfirm ? 'Masquer' : 'Afficher'} le mot de passe {showConfirm ? t('resetPassword.hidePassword') : t('resetPassword.showPassword')}
</button> </button>
</div> </div>
@@ -194,11 +196,11 @@ function ResetPasswordForm() {
> >
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="me-2 h-4 w-4 animate-spin" />
Reinitialisation... {t('resetPassword.resetting')}
</> </>
) : ( ) : (
'Reinitialiser le mot de passe' t('resetPassword.resetPassword')
)} )}
</Button> </Button>
</form> </form>
@@ -207,7 +209,7 @@ function ResetPasswordForm() {
<p className="text-center text-sm text-muted-foreground"> <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"> <Link href="/auth/login" className="text-primary hover:underline font-medium inline-flex items-center gap-1">
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
Retour a la connexion {t('resetPassword.backToLogin')}
</Link> </Link>
</p> </p>
</CardContent> </CardContent>
@@ -216,6 +218,7 @@ function ResetPasswordForm() {
} }
function LoadingFallback() { function LoadingFallback() {
const { t } = useI18n();
return ( return (
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8"> <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" /> <Languages className="h-6 w-6" />
</div> </div>
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <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> </div>
</div> </div>

View File

@@ -7,7 +7,6 @@ import {
Languages, Languages,
Menu, Menu,
X, X,
ChevronLeft,
LogOut LogOut
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -144,13 +143,6 @@ export function DashboardHeader() {
<LogOut className="size-4 shrink-0" /> <LogOut className="size-4 shrink-0" />
{t('dashboard.sidebar.signOut')} {t('dashboard.sidebar.signOut')}
</button> </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> </nav>
</div> </div>
)} )}

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Languages, ChevronLeft, LogOut } from 'lucide-react'; import { Languages, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -83,17 +83,14 @@ export function DashboardSidebar() {
{translateTier(t, user.tier)} {translateTier(t, user.tier)}
</Badge> </Badge>
</div> </div>
<ThemeToggle />
</div> </div>
<Separator /> <Separator />
</> </>
)} )}
{/* Actions */} {/* Actions */}
<div className="px-3 py-3 space-y-1"> <div className="px-3 py-3">
<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>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -103,12 +100,6 @@ export function DashboardSidebar() {
<LogOut className="size-3.5" /> <LogOut className="size-3.5" />
{t('dashboard.sidebar.signOut')} {t('dashboard.sidebar.signOut')}
</Button> </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>
</div> </div>
</aside> </aside>

View File

@@ -17,6 +17,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import type { ApiKey } from './types'; import type { ApiKey } from './types';
interface ApiKeyTableProps { interface ApiKeyTableProps {
@@ -25,23 +26,8 @@ interface ApiKeyTableProps {
isRevoking: boolean; 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) { export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
const { t } = useI18n();
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const copyPrefix = (keyId: string, prefix: string) => { const copyPrefix = (keyId: string, prefix: string) => {
@@ -53,7 +39,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
if (keys.length === 0) { if (keys.length === 0) {
return ( return (
<div className="rounded-lg border border-border p-8 text-center"> <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> </div>
); );
} }
@@ -64,22 +50,22 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Name {t('apiKeys.table.name')}
</TableHead> </TableHead>
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Key {t('apiKeys.table.prefix')}
</TableHead> </TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell"> <TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
Created {t('apiKeys.table.created')}
</TableHead> </TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell"> <TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Last Used {t('apiKeys.table.lastUsed')}
</TableHead> </TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell"> <TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Uses {t('apiKeys.table.actions')}
</TableHead> </TableHead>
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground"> <TableHead className="text-end text-xs font-medium uppercase tracking-wider text-muted-foreground">
Actions {t('apiKeys.table.actions')}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -95,10 +81,10 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
</code> </code>
</TableCell> </TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell"> <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>
<TableCell className="hidden text-muted-foreground lg:table-cell"> <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>
<TableCell className="hidden lg:table-cell"> <TableCell className="hidden lg:table-cell">
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
@@ -113,7 +99,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => copyPrefix(key.id, key.key_prefix)} onClick={() => copyPrefix(key.id, key.key_prefix)}
aria-label="Copy key prefix" aria-label={t('apiKeys.table.copyPrefix')}
> >
{copiedId === key.id ? ( {copiedId === key.id ? (
<Check className="size-3.5 text-accent" /> <Check className="size-3.5 text-accent" />
@@ -123,7 +109,7 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{copiedId === key.id ? 'Copied!' : 'Copy prefix'} {copiedId === key.id ? 'Copied!' : t('apiKeys.table.copyPrefix')}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -134,12 +120,12 @@ export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
size="icon-sm" size="icon-sm"
onClick={() => onRevoke(key)} onClick={() => onRevoke(key)}
disabled={isRevoking} disabled={isRevoking}
aria-label="Revoke key" aria-label={t('apiKeys.table.revokeKey')}
> >
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" /> <Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Revoke</TooltipContent> <TooltipContent>{t('apiKeys.table.revoke')}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</TableCell> </TableCell>

View File

@@ -13,6 +13,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useI18n } from '@/lib/i18n';
import type { ApiKeyCreateResponse } from './types'; import type { ApiKeyCreateResponse } from './types';
const MAX_KEY_NAME_LENGTH = 100; const MAX_KEY_NAME_LENGTH = 100;
@@ -38,6 +39,7 @@ export function GenerateKeyDialog({
isGenerating, isGenerating,
maxKeysReached, maxKeysReached,
}: GenerateKeyDialogProps) { }: GenerateKeyDialogProps) {
const { t } = useI18n();
const [step, setStep] = useState<'name' | 'result'>('name'); const [step, setStep] = useState<'name' | 'result'>('name');
const [keyName, setKeyName] = useState(''); const [keyName, setKeyName] = useState('');
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null); const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
@@ -46,24 +48,24 @@ export function GenerateKeyDialog({
const validation = useMemo<ValidationResult>(() => { const validation = useMemo<ValidationResult>(() => {
const trimmedName = keyName.trim(); const trimmedName = keyName.trim();
if (trimmedName.length > MAX_KEY_NAME_LENGTH) { 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)) { if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
return { return {
isValid: false, isValid: false,
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores' error: t('apiKeys.dialog.nameInvalid'),
}; };
} }
return { isValid: true, error: null }; return { isValid: true, error: null };
}, [keyName]); }, [keyName]); // eslint-disable-line react-hooks/exhaustive-deps
const handleGenerate = async () => { const handleGenerate = async () => {
if (!validation.isValid) return; if (!validation.isValid) return;
try { try {
const result = await onGenerate(keyName.trim() || undefined); const result = await onGenerate(keyName.trim() || undefined);
setGeneratedKey(result); setGeneratedKey(result);
@@ -96,13 +98,13 @@ export function GenerateKeyDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Maximum Keys Reached</DialogTitle> <DialogTitle>{t('apiKeys.dialog.maxTitle')}</DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button onClick={() => onOpenChange(false)}>Close</Button> <Button onClick={() => onOpenChange(false)}>{t('apiKeys.dialog.close')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -118,25 +120,25 @@ export function GenerateKeyDialog({
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
<CheckCircle2 className="h-5 w-5 text-accent" /> <CheckCircle2 className="h-5 w-5 text-accent" />
</div> </div>
<DialogTitle>API Key Generated!</DialogTitle> <DialogTitle>{t('apiKeys.dialog.generated')}</DialogTitle>
</div> </div>
<DialogDescription> <DialogDescription>
Your new API key has been created. Copy it now - it won't be shown again. {t('apiKeys.dialog.generatedDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <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="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"> <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" /> <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"> <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> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label> <Label htmlFor="apiKey">{t('apiKeys.dialog.apiKey')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="apiKey" id="apiKey"
@@ -153,15 +155,15 @@ export function GenerateKeyDialog({
</Button> </Button>
</div> </div>
</div> </div>
<div className="text-sm text-muted-foreground"> <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>
</div> </div>
<DialogFooter> <DialogFooter>
<Button onClick={handleClose}> <Button onClick={handleClose}>
{copied ? 'Done' : 'I\'ve copied the key'} {copied ? t('apiKeys.dialog.done') : t('apiKeys.dialog.copied')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -173,18 +175,18 @@ export function GenerateKeyDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Generate New API Key</DialogTitle> <DialogTitle>{t('apiKeys.dialog.generateTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
Create a new API key for programmatic access to the translation API. {t('apiKeys.dialog.generateDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="keyName">Key Name (optional)</Label> <Label htmlFor="keyName">{t('apiKeys.dialog.keyName')}</Label>
<Input <Input
id="keyName" id="keyName"
placeholder="e.g., Production, Staging" placeholder={t('apiKeys.dialog.keyNamePlaceholder')}
value={keyName} value={keyName}
onChange={(e) => { onChange={(e) => {
setKeyName(e.target.value); setKeyName(e.target.value);
@@ -197,9 +199,9 @@ export function GenerateKeyDialog({
aria-describedby={touched && validation.error ? 'keyName-error' : undefined} aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
A descriptive name to help you identify this key later. {t('apiKeys.dialog.keyNameHint')}
{keyName.length > 0 && ( {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> </p>
{touched && validation.error && ( {touched && validation.error && (
@@ -209,13 +211,13 @@ export function GenerateKeyDialog({
)} )}
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel {t('apiKeys.dialog.cancel')}
</Button> </Button>
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}> <Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
{isGenerating ? 'Generating...' : 'Generate Key'} {isGenerating ? t('apiKeys.dialog.generating') : t('apiKeys.dialog.generate')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -4,8 +4,11 @@ import { Key, Sparkles } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Link from 'next/link'; import Link from 'next/link';
import { useI18n } from '@/lib/i18n';
export function ProUpgradePrompt() { export function ProUpgradePrompt() {
const { t } = useI18n();
return ( return (
<div className="flex items-center justify-center min-h-[60vh] p-6"> <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"> <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"> <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" /> <Key className="h-8 w-8 text-accent" />
</div> </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"> <CardDescription className="text-base">
Automate your translations with API access {t('apiKeys.upgrade.subtitle')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="text-center space-y-6"> <CardContent className="text-center space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" /> <Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Generate unlimited API keys</span> <span>{t('apiKeys.upgrade.feat1')}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" /> <Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Automate document translation</span> <span>{t('apiKeys.upgrade.feat2')}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" /> <Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Webhook notifications</span> <span>{t('apiKeys.upgrade.feat3')}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" /> <Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>LLM translation modes</span> <span>{t('apiKeys.upgrade.feat4')}</span>
</div> </div>
</div> </div>
<div className="pt-2"> <div className="pt-2">
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
API Keys are a <span className="text-accent font-medium">Pro</span> feature. {t('apiKeys.upgrade.proFeature', { pro: t('apiKeys.upgrade.pro') })}
Upgrade to unlock API automation.
</p> </p>
<Button asChild className="w-full bg-accent hover:bg-accent/90"> <Button asChild className="w-full bg-accent hover:bg-accent/90">
<Link href="/pricing"> <Link href="/pricing">
Upgrade to Pro {t('apiKeys.upgrade.cta')}
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
interface RevokeKeyDialogProps { interface RevokeKeyDialogProps {
open: boolean; open: boolean;
@@ -26,21 +27,18 @@ export function RevokeKeyDialog({
isRevoking, isRevoking,
keyName, keyName,
}: RevokeKeyDialogProps) { }: RevokeKeyDialogProps) {
const { t } = useI18n();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle> <DialogTitle>{t('apiKeys.revokeDialog.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to revoke this API key? {keyName ? t('apiKeys.revokeDialog.desc', { name: keyName }) : t('apiKeys.revokeDialog.desc', { name: '' })}
{keyName && (
<span className="block mt-1 font-medium text-foreground">
"{keyName}"
</span>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4"> <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" /> <AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
@@ -52,21 +50,21 @@ export function RevokeKeyDialog({
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={isRevoking} disabled={isRevoking}
> >
Cancel {t('apiKeys.revokeDialog.cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={onConfirm} onClick={onConfirm}
disabled={isRevoking} disabled={isRevoking}
> >
{isRevoking ? 'Revoking...' : 'Revoke Key'} {isRevoking ? 'Revoking...' : t('apiKeys.table.revoke')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -15,8 +15,10 @@ import { GenerateKeyDialog } from './GenerateKeyDialog';
import { RevokeKeyDialog } from './RevokeKeyDialog'; import { RevokeKeyDialog } from './RevokeKeyDialog';
import { WebhookSnippet } from './WebhookSnippet'; import { WebhookSnippet } from './WebhookSnippet';
import { useToast } from '@/components/ui/toast'; import { useToast } from '@/components/ui/toast';
import { useI18n } from '@/lib/i18n';
export default function ApiKeysPage() { export default function ApiKeysPage() {
const { t } = useI18n();
const { data: user, isLoading: isLoadingUser } = useUser(); const { data: user, isLoading: isLoadingUser } = useUser();
const { const {
keys, keys,
@@ -44,16 +46,15 @@ export default function ApiKeysPage() {
// Handle API errors with specific error codes // Handle API errors with specific error codes
useEffect(() => { useEffect(() => {
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') { if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
// Redirect to upgrade prompt will happen via isPro check
setApiError(null); setApiError(null);
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') { } 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) { } else if (errorDetails) {
setApiError(errorDetails.message); setApiError(errorDetails.message);
} else { } else {
setApiError(null); setApiError(null);
} }
}, [errorDetails]); }, [errorDetails]); // eslint-disable-line react-hooks/exhaustive-deps
const handleRevokeClick = (key: ApiKey) => { const handleRevokeClick = (key: ApiKey) => {
setKeyToRevoke({ id: key.id, name: key.name }); setKeyToRevoke({ id: key.id, name: key.name });
@@ -67,22 +68,22 @@ export default function ApiKeysPage() {
setRevokeDialogOpen(false); setRevokeDialogOpen(false);
setKeyToRevoke(null); setKeyToRevoke(null);
toast({ toast({
title: 'Key revoked', title: t('apiKeys.keyRevoked'),
description: 'The API key has been revoked successfully.', description: t('apiKeys.keyRevokedDesc'),
}); });
} catch (error) { } catch (error) {
const revokeError = parseRevokeError(); const revokeError = parseRevokeError();
if (revokeError?.code === 'API_KEY_NOT_FOUND') { if (revokeError?.code === 'API_KEY_NOT_FOUND') {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Key Not Found', title: t('apiKeys.keyNotFound'),
description: 'The API key no longer exists. It may have already been revoked.', description: t('apiKeys.keyNotFoundDesc'),
}); });
} else { } else {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Error', title: t('apiKeys.error'),
description: revokeError?.message || 'Failed to revoke the API key. Please try again.', description: revokeError?.message || t('apiKeys.revokeError'),
}); });
} }
} }
@@ -97,20 +98,20 @@ export default function ApiKeysPage() {
if (genError?.code === 'API_KEY_LIMIT_REACHED') { if (genError?.code === 'API_KEY_LIMIT_REACHED') {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Limit Reached', title: t('apiKeys.limitReached'),
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.', description: t('apiKeys.limitReachedDesc'),
}); });
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') { } else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Pro Feature Required', title: t('apiKeys.proRequired'),
description: 'API keys are a Pro feature. Please upgrade your account.', description: t('apiKeys.proRequiredDesc'),
}); });
} else { } else {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Error', title: t('apiKeys.error'),
description: genError?.message || 'Failed to generate API key. Please try again.', description: genError?.message || t('apiKeys.generateError'),
}); });
} }
throw error; throw error;
@@ -122,7 +123,7 @@ export default function ApiKeysPage() {
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4"> <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> <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>
</div> </div>
); );
@@ -135,9 +136,9 @@ export default function ApiKeysPage() {
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
<div> <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"> <p className="text-muted-foreground">
Manage your API keys for programmatic access to the translation API. {t('apiKeys.subtitle')}
</p> </p>
</div> </div>
@@ -155,8 +156,8 @@ export default function ApiKeysPage() {
<Zap className="size-4 text-accent" /> <Zap className="size-4 text-accent" />
</div> </div>
<div> <div>
<CardTitle className="text-base">API & Automation</CardTitle> <CardTitle className="text-base">{t('apiKeys.sectionTitle')}</CardTitle>
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription> <CardDescription>{t('apiKeys.sectionDesc')}</CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -165,13 +166,13 @@ export default function ApiKeysPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{total} of {MAX_API_KEYS} keys used {t('apiKeys.keysUsed', { total, max: MAX_API_KEYS })}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{maxKeysReached ? ( {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> </p>
</div> </div>
@@ -181,7 +182,7 @@ export default function ApiKeysPage() {
className="gap-1.5" className="gap-1.5"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Generate New Key {t('apiKeys.generateNew')}
</Button> </Button>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { apiClient, ApiClientError } from '@/lib/apiClient';
import type { import type {
ApiKey, ApiKey,
ApiKeyCreateResponse, ApiKeyCreateResponse,
ApiKeyCreateApiResponse,
ApiKeyRevokeResponse, ApiKeyRevokeResponse,
} from './types'; } from './types';
@@ -35,8 +36,7 @@ export function useApiKeys() {
} = useQuery<ApiKeysListApiResponse, ApiClientError>({ } = useQuery<ApiKeysListApiResponse, ApiClientError>({
queryKey: API_KEYS_QUERY_KEY, queryKey: API_KEYS_QUERY_KEY,
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys'); return apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
return response.data;
}, },
retry: (failureCount, err) => { retry: (failureCount, err) => {
if (err.status === 403 || err.status === 429) return false; if (err.status === 403 || err.status === 429) return false;
@@ -49,7 +49,7 @@ export function useApiKeys() {
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({ const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => { 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', name: name || 'API Key',
}); });
return response.data; return response.data;
@@ -61,8 +61,7 @@ export function useApiKeys() {
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({ const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => { mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`); return apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
return response.data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });

View File

@@ -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 { export interface NavItem {
labelKey: string; labelKey: string;
@@ -10,7 +10,8 @@ export interface NavItem {
export const baseNavItems: NavItem[] = [ export const baseNavItems: NavItem[] = [
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText }, { labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User }, { 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 = { export const proNavItem: NavItem = {
@@ -21,5 +22,6 @@ export const proNavItem: NavItem = {
}; };
export function getNavItems(isPro: boolean): NavItem[] { export function getNavItems(isPro: boolean): NavItem[] {
return isPro ? [...baseNavItems, proNavItem] : baseNavItems; if (isPro) return [...baseNavItems, proNavItem];
return baseNavItems.filter(item => !item.proOnly);
} }

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

View File

@@ -95,7 +95,7 @@ function TemplateCard({
onClick={() => onSelect(template)} onClick={() => onSelect(template)}
disabled={isLoading} disabled={isLoading}
className={cn( 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 colorClass
)} )}
> >
@@ -477,7 +477,7 @@ export function CreateGlossaryDialog({
{t('glossaries.dialog.cancel')} {t('glossaries.dialog.cancel')}
</Button> </Button>
<Button onClick={handleSubmit} disabled={!canSubmit}> <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} {submitLabel}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react'; import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types'; import type { GlossaryListItem } from './types';
import { useI18n } from '@/lib/i18n'; import { useI18n, formatDate } from '@/lib/i18n';
interface GlossaryCardProps { interface GlossaryCardProps {
glossary: GlossaryListItem; glossary: GlossaryListItem;
@@ -30,7 +30,7 @@ export const GlossaryCard = memo(function GlossaryCard({
onDelete(glossary.id, glossary.name); onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]); }, [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', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',

View File

@@ -60,8 +60,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
} = useQuery<GlossaryListResponse, ApiClientError>({ } = useQuery<GlossaryListResponse, ApiClientError>({
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage], queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`); return apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
return response.data;
}, },
retry: (failureCount, err) => { retry: (failureCount, err) => {
if (err.status === 403 || err.status === 401) return false; if (err.status === 403 || err.status === 401) return false;
@@ -80,7 +79,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => { mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input); const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
return response.data.data; return response.data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -90,7 +89,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => { mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data); const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
return response.data.data; return response.data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -125,7 +124,7 @@ export function useGlossaries(options: UseGlossariesOptions = {}) {
const response = await apiClient.post<GlossaryDetailResponse>( const response = await apiClient.post<GlossaryDetailResponse>(
`/api/v1/glossaries/import?${params.toString()}` `/api/v1/glossaries/import?${params.toString()}`
); );
return response.data.data; return response.data;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY }); queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
@@ -191,8 +190,7 @@ export function useGlossaryTemplates() {
const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({ const { data, isLoading, error } = useQuery<GlossaryTemplatesResponse, ApiClientError>({
queryKey: ['glossary-templates'], queryKey: ['glossary-templates'],
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list'); return apiClient.get<GlossaryTemplatesResponse>('/api/v1/glossaries/templates/list');
return response.data;
}, },
staleTime: 5 * 60 * 1000, // templates rarely change staleTime: 5 * 60 * 1000, // templates rarely change
retry: 1, retry: 1,
@@ -214,8 +212,7 @@ export function useGlossary(id: string | null) {
queryKey: [...GLOSSARIES_QUERY_KEY, id], queryKey: [...GLOSSARIES_QUERY_KEY, id],
queryFn: async () => { queryFn: async () => {
if (!id) throw new Error('Glossary ID is required'); if (!id) throw new Error('Glossary ID is required');
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`); return apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
return response.data;
}, },
enabled: !!id, enabled: !!id,
retry: (failureCount, err) => { retry: (failureCount, err) => {

View File

@@ -4,67 +4,59 @@ import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { API_BASE } from '@/lib/config'; import { API_BASE } from '@/lib/config';
import { useI18n, type Locale } from '@/lib/i18n'; import { useI18n, type Locale, formatDate } from '@/lib/i18n';
import { import {
User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket, User, Mail, Calendar, Crown, Zap, Sparkles, Building2, Rocket,
FileText, Layers, CreditCard, TrendingUp, AlertTriangle, FileText, Layers, CreditCard, TrendingUp, AlertTriangle,
CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight, CheckCircle2, XCircle, RefreshCw, ExternalLink, ArrowRight,
BadgeCheck, ShieldAlert, Info, Globe, BadgeCheck, ShieldAlert, Info, Globe, Settings, Palette, Trash2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; 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'; import { cn } from '@/lib/utils';
/* ─────────────── helpers ─────────────── */ /* ── helpers ──────────────────────────────────────────────────── */
const PLAN_ICONS: Record<string, React.ElementType> = { const PLAN_ICONS: Record<string, React.ElementType> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket, free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
}; };
const PLAN_COLORS: Record<string, { badge: string; gradient: string; ring: string }> = { 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' }, 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' }, 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' }, 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' }, 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> = { const PLAN_LABELS: Record<string, string> = {
free: 'Gratuit', starter: 'Starter', pro: 'Pro', business: 'Business', enterprise: 'Entreprise', 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,
}; };
const PLAN_PRICES: Record<string, number> = { free: 0, starter: 9, pro: 19, business: 49 };
function getInitials(name?: string) { function getInitials(name?: string) {
if (!name) return '??'; 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) { function pct(used: number, limit: number) {
if (limit === -1 || limit === 0) return 0; if (limit === -1 || limit === 0) return 0;
return Math.min(100, Math.round((used / limit) * 100)); return Math.min(100, Math.round((used / limit) * 100));
} }
function fmtLimit(val: number) { return val === -1 ? '∞' : String(val); }
function fmtLimit(val: number) { function nextResetDate(locale: Locale) {
return val === -1 ? '∞' : String(val);
}
function nextResetDate() {
const now = new Date(); const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); 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 { function UsageBar({ label, used, limit, icon }: { label: string; used: number; limit: number; icon: React.ReactNode }) {
label: string;
used: number;
limit: number;
icon: React.ReactNode;
}
function UsageBar({ label, used, limit, icon }: UsageBarProps) {
const p = pct(used, limit); const p = pct(used, limit);
const isUnlimited = limit === -1; const isUnlimited = limit === -1;
return ( return (
@@ -81,13 +73,7 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
</div> </div>
{!isUnlimited && ( {!isUnlimited && (
<div className="h-1.5 bg-secondary rounded-full overflow-hidden"> <div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div <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}%` }} />
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>
)} )}
</div> </div>
@@ -95,14 +81,26 @@ function UsageBar({ label, used, limit, icon }: UsageBarProps) {
} }
const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [ const UI_LANGUAGES: { value: Locale; label: string; flag: string }[] = [
{ value: 'fr', label: 'Français', flag: '🇫🇷' },
{ value: 'en', label: 'English', 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() { export default function ProfilePage() {
const router = useRouter(); const router = useRouter();
const { locale, setLocale } = useI18n(); const { locale, setLocale, t } = useI18n();
const { settings, updateSettings } = useTranslationStore();
const [user, setUser] = useState<any>(null); const [user, setUser] = useState<any>(null);
const [usage, setUsage] = 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 [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [loadingPortal, setLoadingPortal] = useState(false); const [loadingPortal, setLoadingPortal] = useState(false);
const [loadingCancel, setLoadingCancel] = 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 token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const authHeaders = { Authorization: `Bearer ${token}` }; 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 (meRes.ok) { const j = await meRes.json(); setUser(j.data ?? j); }
if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); } if (usageRes.ok) { const j = await usageRes.json(); setUsage(j.data ?? j); }
} catch { } catch { /* ignore */ } finally { setLoading(false); }
// ignore
} finally {
setLoading(false);
}
}, [token]); // eslint-disable-line react-hooks/exhaustive-deps }, [token]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { fetchData(); }, [fetchData]); useEffect(() => { fetchData(); }, [fetchData]);
useEffect(() => { setDefaultLanguage(settings.defaultTargetLanguage); }, [settings.defaultTargetLanguage]);
const handleBillingPortal = async () => { const handleBillingPortal = async () => {
setLoadingPortal(true); setLoadingPortal(true);
@@ -140,32 +137,43 @@ export default function ProfilePage() {
const j = await res.json(); const j = await res.json();
const url = j.data?.url ?? j.url; const url = j.data?.url ?? j.url;
if (url) window.open(url, '_blank'); if (url) window.open(url, '_blank');
else setStatusMsg({ type: 'err', text: 'Portail de facturation non disponible (Stripe non configuré).' }); else setStatusMsg({ type: 'err', text: t('profile.subscription.billingUnavailable') });
} catch { } catch { setStatusMsg({ type: 'err', text: t('profile.subscription.billingError') }); }
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'accès au portail.' }); finally { setLoadingPortal(false); }
} finally {
setLoadingPortal(false);
}
}; };
const handleCancel = async () => { const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; } if (!cancelConfirm) { setCancelConfirm(true); return; }
setLoadingCancel(true); setLoadingCancel(true);
try { try {
const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { const res = await fetch(`${API_BASE}/api/v1/auth/cancel-subscription`, { method: 'POST', headers: authHeaders });
method: 'POST', headers: authHeaders,
});
if (res.ok) { 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); setCancelConfirm(false);
fetchData(); fetchData();
} else { } else { setStatusMsg({ type: 'err', text: t('profile.subscription.cancelError') }); }
setStatusMsg({ type: 'err', text: 'Erreur lors de l\'annulation. Contactez le support.' }); } 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 { await new Promise(resolve => setTimeout(resolve, 500));
setStatusMsg({ type: 'err', text: 'Erreur réseau.' }); window.location.reload();
} finally { } catch (error) {
setLoadingCancel(false); console.error('Error clearing cache:', error);
setIsClearing(false);
} }
}; };
@@ -178,7 +186,7 @@ export default function ProfilePage() {
} }
const planId = user?.plan ?? user?.tier ?? 'free'; 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 planColors = PLAN_COLORS[planId] ?? PLAN_COLORS.free;
const PlanIcon = PLAN_ICONS[planId] ?? Sparkles; const PlanIcon = PLAN_ICONS[planId] ?? Sparkles;
const planPrice = PLAN_PRICES[planId]; const planPrice = PLAN_PRICES[planId];
@@ -193,15 +201,15 @@ export default function ProfilePage() {
return ( return (
<div className="flex h-full flex-col overflow-y-auto"> <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> <div>
<h1 className="text-2xl font-bold text-foreground">Mon Profil</h1> <h1 className="text-2xl font-bold text-foreground">{t('profile.header.title')}</h1>
<p className="text-sm text-muted-foreground mt-1">Gérez votre compte, votre abonnement et votre utilisation.</p> <p className="text-sm text-muted-foreground mt-1">{t('profile.header.subtitle')}</p>
</div> </div>
{/* ── Status message ── */} {/* Status message */}
{statusMsg && ( {statusMsg && (
<div className={cn( <div className={cn(
'flex items-start gap-3 p-4 rounded-xl border text-sm', '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-success/10 border-success/30 text-success'
: 'bg-destructive/10 border-destructive/30 text-destructive' : 'bg-destructive/10 border-destructive/30 text-destructive'
)}> )}>
{statusMsg.type === 'ok' {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" />}
? <CheckCircle2 className="w-4 h-4 flex-shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />}
<span className="flex-1">{statusMsg.text}</span> <span className="flex-1">{statusMsg.text}</span>
<button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground"></button> <button onClick={() => setStatusMsg(null)} className="text-muted-foreground hover:text-foreground"></button>
</div> </div>
)} )}
{/* ── Two-column grid on desktop ── */} {/* Tabs */}
<div className="grid gap-6 lg:grid-cols-2 lg:items-start"> <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 ── */} {/* ── Tab: Account ────────────────────────────────────── */}
<div className="flex flex-col gap-6"> <TabsContent value="account" className="space-y-6 pt-4">
{/* Identity card */}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-5">
<div className={cn( <div className={cn(
'flex shrink-0 items-center justify-center w-16 h-16 rounded-2xl text-white font-bold text-xl ring-4', '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}`, `bg-gradient-to-br ${planColors.gradient}`, planColors.ring
planColors.ring
)}> )}>
{getInitials(user?.name)} {getInitials(user?.name)}
</div> </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"> <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)}> <Badge className={cn('text-xs font-medium', planColors.badge)}>
<PlanIcon className="w-3 h-3 mr-1" /> <PlanIcon className="w-3 h-3 me-1" />{planLabel}
{planLabel}
</Badge> </Badge>
</div> </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" /> <Mail className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{user?.email}</span> <span className="truncate">{user?.email}</span>
</div> </div>
{user?.created_at && ( {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" /> <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> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Subscription card */} {/* ── Tab: Subscription ───────────────────────────────── */}
<TabsContent value="subscription" className="space-y-6 pt-4">
{/* Plan card */}
<Card> <Card>
<CardHeader className="pb-3"> <CardContent className="pt-6 space-y-4">
<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">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn('p-2.5 rounded-xl bg-gradient-to-br', planColors.gradient)}> <div className={cn('p-3 rounded-xl bg-gradient-to-br', planColors.gradient)}>
<PlanIcon className="w-5 h-5 text-white" /> <PlanIcon className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="font-semibold text-foreground">Forfait {planLabel}</p> <p className="font-semibold text-foreground text-lg">{t('profile.plan.label')} {planLabel}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">{isFreePlan ? t('profile.plan.free') : t('profile.plan.pricePerMonth', { price: planPrice })}</p>
{isFreePlan ? 'Gratuit' : `${planPrice} €/mois`}
</p>
</div> </div>
</div> </div>
{!isFreePlan && ( {!isFreePlan && (
@@ -285,168 +288,89 @@ export default function ProfilePage() {
user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' : user?.subscription_status === 'active' ? 'text-success border-success/30 bg-success/10' :
'text-destructive border-destructive/30 bg-destructive/10' 'text-destructive border-destructive/30 bg-destructive/10'
)}> )}>
{isCanceling ? 'Annulation en cours' : {isCanceling ? t('profile.subscription.canceling') : user?.subscription_status === 'active' ? t('profile.subscription.active') : user?.subscription_status ?? t('profile.subscription.unknown')}
user?.subscription_status === 'active' ? 'Actif' :
user?.subscription_status ?? 'Inconnu'}
</Badge> </Badge>
)} )}
</div> </div>
{!isFreePlan && user?.subscription_ends_at && ( {!isFreePlan && user?.subscription_ends_at && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted text-sm"> <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"> <span className="text-muted-foreground">
{isCanceling {isCanceling
? `Accès jusqu'au ${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)}`
: `Renouvellement le ${new Date(user.subscription_ends_at).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}`} : `${t('profile.subscription.renewalOn')} ${formatDate(new Date(user.subscription_ends_at), locale)}`}
</span> </span>
</div> </div>
)} )}
<Separator />
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button asChild size="sm" className="gap-2"> <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>
<Link href="/pricing">
<TrendingUp className="w-3.5 h-3.5" />
{isFreePlan ? 'Passer à un forfait payant' : 'Changer de forfait'}
</Link>
</Button>
{!isFreePlan && ( {!isFreePlan && (
<Button <Button variant="outline" size="sm" onClick={handleBillingPortal} disabled={loadingPortal}>
variant="outline" {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" />}
size="sm" {t('profile.subscription.manageBilling')}
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> </Button>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Danger zone */} {/* Usage */}
{!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 */}
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<BadgeCheck className="w-4 h-4 text-primary" /> <BadgeCheck className="w-4 h-4 text-primary" />
Utilisation ce mois-ci {t('profile.usage.title')}
<span className="ml-auto text-xs text-muted-foreground font-normal"> <span className="ms-auto text-xs text-muted-foreground font-normal">{t('profile.usage.resetOn')} {nextResetDate(locale)}</span>
Réinitialisation le {nextResetDate()}
</span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<UsageBar <UsageBar label={t('profile.usage.documents')} used={docsUsed} limit={docsLimit} icon={<FileText className="w-4 h-4" />} />
label="Documents traduits" <UsageBar label={t('profile.usage.pages')} used={pagesUsed} limit={pagesLimit} icon={<Layers className="w-4 h-4" />} />
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" />}
/>
{extraCredits > 0 && ( {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"> <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" /> <Info className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
<span className="text-amber-700 dark:text-amber-300"> <span className="text-amber-700 dark:text-amber-300">{extraCredits} {extraCredits > 1 ? t('profile.usage.extraCreditsPlural') : t('profile.usage.extraCredits')}</span>
{extraCredits} crédit{extraCredits > 1 ? 's' : ''} supplémentaire{extraCredits > 1 ? 's' : ''} disponible{extraCredits > 1 ? 's' : ''}
</span>
</div> </div>
)} )}
{usage?.upgrade_required && ( {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"> <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"> <div className="flex-1">
<p className="text-red-700 dark:text-red-300 font-medium">Quota atteint</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">Passez à un forfait supérieur pour continuer à traduire.</p> <p className="text-red-600 dark:text-red-400 text-xs mt-0.5">{t('profile.usage.quotaReachedDesc')}</p>
</div> </div>
<Button asChild size="sm" className="bg-red-600 hover:bg-red-500 flex-shrink-0"> <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>
<Link href="/pricing"><ArrowRight className="w-3.5 h-3.5" /></Link>
</Button>
</div> </div>
)} )}
{isFreePlan && ( {isFreePlan && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-primary/10 border border-primary/20 text-sm"> <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" /> <Zap className="w-4 h-4 text-primary shrink-0" />
<span className="text-primary flex-1">Débloquez plus de traductions avec un forfait payant.</span> <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 flex-shrink-0"> <Button asChild size="sm" variant="outline" className="border-primary/30 text-primary hover:bg-primary/10 shrink-0">
<Link href="/pricing">Voir les forfaits <ArrowRight className="w-3.5 h-3.5 ml-1" /></Link> <Link href="/pricing">{t('profile.usage.viewPlans')} <ArrowRight className="w-3.5 h-3.5 ms-1" /></Link>
</Button> </Button>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Features included */} {/* Features */}
{user?.plan_limits?.features?.length > 0 && ( {user?.plan_limits?.features?.length > 0 && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" /> <CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400" />
Inclus dans votre forfait {t('profile.usage.includedInPlan')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <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) => ( {user.plan_limits.features.map((f: string, i: number) => (
<div key={i} className="flex items-start gap-2 text-sm"> <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" /> <CheckCircle2 className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0 mt-0.5" />
<span className="text-muted-foreground">{f}</span> <span className="text-muted-foreground">{f.includes('.') ? t(f) : f}</span>
</div> </div>
))} ))}
</div> </div>
@@ -454,43 +378,124 @@ export default function ProfilePage() {
</Card> </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> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"> <CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4 text-primary" /> <Globe className="w-4 h-4 text-primary" /> {t('profile.prefs.interfaceLang')}
Préférences
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between gap-4"> <div className="flex flex-wrap gap-2">
<div> {UI_LANGUAGES.map((lang) => (
<p className="text-sm font-medium text-foreground">Langue de l'interface</p> <button
<p className="text-xs text-muted-foreground mt-0.5">Choisissez la langue d'affichage</p> key={lang.value}
</div> onClick={() => setLocale(lang.value)}
<div className="flex gap-2 shrink-0"> className={cn(
{UI_LANGUAGES.map((lang) => ( 'flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
<button locale === lang.value
key={lang.value} ? 'bg-primary/10 border-primary/40 text-primary'
onClick={() => setLocale(lang.value)} : 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80'
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 <span>{lang.flag}</span><span>{lang.label}</span>
? 'bg-primary/10 border-primary/40 text-primary' </button>
: 'bg-transparent border-border text-muted-foreground hover:text-foreground hover:border-border/80' ))}
)} </div>
> <p className="text-xs text-muted-foreground mt-3">{t('profile.prefs.interfaceLangDesc')}</p>
<span>{lang.flag}</span> </CardContent>
<span>{lang.label}</span> </Card>
</button>
{/* 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> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> {/* Theme */}
</div> <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>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
"use client"; 'use client';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from "lucide-react"; import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from 'lucide-react';
import { API_BASE } from "@/lib/config"; import { API_BASE } from '@/lib/config';
import { useI18n } from '@/lib/i18n';
const FALLBACK_PROVIDERS = [ const FALLBACK_PROVIDERS = [
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const }, { id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
@@ -19,6 +20,7 @@ interface AvailableProvider {
} }
export default function TranslationServicesPage() { export default function TranslationServicesPage() {
const { t } = useI18n();
const [providers, setProviders] = useState<AvailableProvider[]>([]); const [providers, setProviders] = useState<AvailableProvider[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -49,27 +51,26 @@ export default function TranslationServicesPage() {
const llmProviders = providers.filter((p) => p.mode === "llm"); const llmProviders = providers.filter((p) => p.mode === "llm");
return ( 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>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<Zap className="h-5 w-5 text-primary" /> <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> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Providers are configured by the administrator. You can see which ones are {t('services.subtitle')}
currently available for your account.
</p> </p>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
<span>Loading providers</span> <span>{t('services.loading')}</span>
</div> </div>
) : providers.length === 0 ? ( ) : providers.length === 0 ? (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
No providers are currently configured. Contact your administrator. {t('services.noProviders')}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@@ -79,7 +80,7 @@ export default function TranslationServicesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="size-4 text-muted-foreground" /> <Globe className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Classic Translation {t('services.classic')}
</h2> </h2>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -87,12 +88,11 @@ export default function TranslationServicesPage() {
<Card key={p.id}> <Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5"> <CardContent className="flex items-center justify-between py-4 px-5">
<div> <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 className="text-xs text-muted-foreground">{p.description}</p>
</div> </div>
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1"> <Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
<CheckCircle2 className="size-3" /> <CheckCircle2 className="size-3" />{t('services.available')}
Available
</Badge> </Badge>
</CardContent> </CardContent>
</Card> </Card>
@@ -106,7 +106,7 @@ export default function TranslationServicesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Brain className="size-4 text-muted-foreground" /> <Brain className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"> <h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
LLM · Context-Aware (Pro) {t('services.llmPro')}
</h2> </h2>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -114,17 +114,16 @@ export default function TranslationServicesPage() {
<Card key={p.id}> <Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5"> <CardContent className="flex items-center justify-between py-4 px-5">
<div> <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 className="text-xs text-muted-foreground">{p.description}</p>
{p.model && ( {p.model && (
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin"> <p className="mt-1 text-[10px] font-mono text-muted-foreground/80">
Modèle : {p.model} {t('services.model')}: {p.model}
</p> </p>
)} )}
</div> </div>
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1"> <Badge variant="outline" className="border-emerald-500/50 text-emerald-600 gap-1">
<CheckCircle2 className="size-3" /> <CheckCircle2 className="size-3" />{t('services.available')}
Available
</Badge> </Badge>
</CardContent> </CardContent>
</Card> </Card>
@@ -135,17 +134,16 @@ export default function TranslationServicesPage() {
</div> </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"> <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" /> <Lock className="size-4" />
Provider configuration is admin-only {t('services.adminOnly.title')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription className="text-amber-700 text-xs"> <CardDescription className="text-amber-700 dark:text-amber-400/80 text-xs">
API keys, model selection, and provider activation are managed exclusively {t('services.adminOnly.desc')}
by the administrator in the admin panel. You never need to enter an API key.
</CardDescription> </CardDescription>
</CardContent> </CardContent>
</Card> </Card>

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

View File

@@ -57,24 +57,32 @@ export function FileDropZone({ upload }: FileDropZoneProps) {
{/* Format badges */} {/* Format badges */}
<div className="flex flex-wrap items-center justify-center gap-3"> <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"> <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" /> <FileText className="size-3.5 text-blue-500" />
Word (.docx) Word (.docx)
</span> </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"> <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" /> <Presentation className="size-3.5 text-orange-500" />
PowerPoint (.pptx) PowerPoint (.pptx)
</span> </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> </div>
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
accept=".xlsx,.docx,.pptx" accept=".xlsx,.docx,.pptx,.pdf"
className="hidden" className="hidden"
onChange={upload.handleFileSelect} onChange={upload.handleFileSelect}
aria-label={t('dashboard.translate.dropzone.uploadAria')} aria-label={t('dashboard.translate.dropzone.uploadAria')}

View File

@@ -40,7 +40,7 @@ export function FilePreview({ file, onRemove }: FilePreviewProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();

View File

@@ -1,14 +1,9 @@
'use client'; 'use client';
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { import { Loader2, AlertCircle, ChevronDown, Check } from 'lucide-react';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import type { Language } from './types'; import type { Language } from './types';
interface LanguageSelectorProps { interface LanguageSelectorProps {
@@ -21,84 +16,179 @@ interface LanguageSelectorProps {
onTargetChange: (value: string) => void; onTargetChange: (value: string) => void;
} }
export function LanguageSelector({ /* ── Combobox dropdown with search ──────────────────────────────── */
sourceLang, function Combobox({
targetLang, value,
languages, options,
isLoading, includeAuto,
error, autoLabel,
onSourceChange, placeholder,
onTargetChange, 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) { }: LanguageSelectorProps) {
const { t } = useI18n(); 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 ( return (
<div className="flex flex-col gap-3"> <div className="space-y-3">
{error && ( {/* Source */}
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive"> <div>
<AlertCircle className="size-3.5 shrink-0" /> <label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
<span>{t('dashboard.translate.language.loadErrorPrefix')} {error}</span> {t('dashboard.translate.language.source')}
</div> </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"> {/* Swap */}
{/* Source language */} <div className="flex justify-center -my-0.5">
<div className="flex flex-1 flex-col gap-1.5"> <button
<label className="text-sm font-medium text-foreground"> type="button"
{t('dashboard.translate.language.source')} onClick={() => canSwap && (() => { const s = sourceLang; onSourceChange(targetLang); onTargetChange(s); })()}
</label> disabled={!canSwap}
<Select value={sourceLang} onValueChange={onSourceChange} disabled={isLoading}> className={cn(
<SelectTrigger className="h-11 w-full"> 'flex size-8 items-center justify-center rounded-full border shadow-sm transition-colors',
{isLoading ? ( canSwap
<div className="flex items-center gap-2 text-muted-foreground"> ? 'border-border bg-background text-muted-foreground hover:border-primary hover:text-primary'
<Loader2 className="size-3.5 animate-spin" /> : 'cursor-not-allowed border-border/50 bg-muted text-muted-foreground/40'
<span>{t('dashboard.translate.language.loading')}</span> )}
</div> title="Inverser"
) : ( >
<SelectValue placeholder={t('dashboard.translate.language.autoDetect')} /> <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>
</SelectTrigger> </div>
<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>
{/* Arrow — flips in RTL via rtl: variant */} {/* Target */}
<div className="mb-2 flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div>
<ArrowRight className="size-4 rtl:rotate-180" /> <label className="mb-1.5 block text-xs font-medium uppercase tracking-wider text-muted-foreground">
</div> {t('dashboard.translate.language.target')}
</label>
{/* Target language */} <Combobox
<div className="flex flex-1 flex-col gap-1.5"> value={targetLang}
<label className="text-sm font-medium text-foreground"> options={languages}
{t('dashboard.translate.language.target')} includeAuto={false}
</label> autoLabel=""
<Select value={targetLang} onValueChange={onTargetChange} disabled={isLoading}> placeholder={t('dashboard.translate.language.selectPlaceholder')}
<SelectTrigger className="h-11 w-full"> onChange={onTargetChange}
{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>
</div> </div>
</div> </div>
); );

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; 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 { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification'; import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config'; import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
interface TranslationCompleteProps { interface TranslationCompleteProps {
jobId: string; jobId: string;
@@ -91,54 +95,78 @@ export function TranslationComplete({
}, []); }, []);
return ( 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"> <div className="flex w-full max-w-lg flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card 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>
{/* Text */} {/* ═══ Success header ═══ */}
<div className="flex flex-col gap-2"> <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">
<h3 className="text-xl font-bold text-foreground"> <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')} {t('dashboard.translate.complete.title')}
</h3> </h3>
<p className="text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{fileName {fileName
? t('dashboard.translate.complete.descNamed', { name: fileName }) ? t('dashboard.translate.complete.descNamed', { name: fileName })
: t('dashboard.translate.complete.descGeneric')} : t('dashboard.translate.complete.descGeneric')}
</p> </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> </div>
{/* Actions — stacked column so text is never cut */} <div className="p-8 space-y-6">
<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>
<Button {/* ═══ Result stats ═══ */}
variant="outline" <div className="grid grid-cols-3 gap-2.5">
size="lg" <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">
className="h-11 w-full gap-2" <FileText className="size-4 text-emerald-600" />
onClick={onNewTranslation} <p className="text-sm font-bold text-foreground">142</p>
> <p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">Segments</p>
<Plus className="size-4" /> </div>
{t('dashboard.translate.complete.newTranslation')} <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">
</Button> <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>
</div> </div>
); );

View File

@@ -39,7 +39,7 @@ export function TranslationModeToggle({
onClick={() => onModeChange('classic')} onClick={() => onModeChange('classic')}
> >
Classic Classic
<span className="ml-1.5 text-xs text-muted-foreground"> <span className="ms-1.5 text-xs text-muted-foreground">
Fast Fast
</span> </span>
</button> </button>
@@ -58,7 +58,7 @@ export function TranslationModeToggle({
disabled={!isPro} disabled={!isPro}
> >
Pro LLM Pro LLM
<span className="ml-1.5 text-xs text-muted-foreground"> <span className="ms-1.5 text-xs text-muted-foreground">
Context-Aware Context-Aware
</span> </span>
{!isPro && ( {!isPro && (

View File

@@ -1,10 +1,16 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react'; import {
import { Progress } from '@/components/ui/progress'; 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'; import { useI18n } from '@/lib/i18n';
/* ── Types ──────────────────────────────────────────────────────── */
interface TranslationProgressProps { interface TranslationProgressProps {
progress: number; progress: number;
currentStep: string; currentStep: string;
@@ -13,8 +19,84 @@ interface TranslationProgressProps {
isPolling?: boolean; isPolling?: boolean;
isUploading?: boolean; isUploading?: boolean;
isCompleted?: 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({ export function TranslationProgress({
progress, progress,
currentStep, currentStep,
@@ -23,101 +105,254 @@ export function TranslationProgress({
isPolling = true, isPolling = true,
isUploading = false, isUploading = false,
isCompleted = false, isCompleted = false,
fileName,
sourceLang,
targetLang,
providerLabel,
onCancel,
}: TranslationProgressProps) { }: TranslationProgressProps) {
const { t } = useI18n(); const { t } = useI18n();
const [animate, setAnimate] = useState(false); 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(() => { useEffect(() => {
if (progress > 0) { if (progress > 0) setAnimate(true);
setAnimate(true); else {
} else if (progress === 0) {
setAnimate(false); setAnimate(false);
const tid = setTimeout(() => setAnimate(true), 50); const tid = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(tid); return () => clearTimeout(tid);
} }
prevProgressRef.current = progress;
}, [progress]); }, [progress]);
function formatTimeRemaining(seconds: number | null): string { // Elapsed timer
if (seconds === null || seconds <= 0) return ''; useEffect(() => {
if (seconds < 60) if (isCompleted || error) {
return t('dashboard.translate.progress.timeSeconds', { seconds: String(seconds) }); if (elapsedRef.current) clearInterval(elapsedRef.current);
const minutes = Math.floor(seconds / 60); return;
const remaining = seconds % 60; }
if (remaining === 0) elapsedRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
return t('dashboard.translate.progress.timeMinutes', { minutes: String(minutes) }); return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); };
return t('dashboard.translate.progress.timeMixed', { }, [isCompleted, error]);
minutes: String(minutes),
seconds: String(remaining),
});
}
// 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) { if (error) {
return ( return (
<div <div className="flex flex-col gap-6">
className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" <div className="rounded-xl bg-destructive/10 border border-destructive/20 p-5" role="alert" aria-live="assertive">
role="alert" <div className="flex items-start gap-3">
aria-live="assertive" <AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" />
> <div>
<div className="flex items-start gap-3"> <p className="text-sm font-semibold text-destructive mb-1">{t('dashboard.translate.progress.failedTitle')}</p>
<AlertTriangle className="size-5 text-destructive shrink-0 mt-0.5" aria-hidden /> <p className="text-sm text-destructive/80">{error}</p>
<div> </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> </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> </div>
); );
} }
const timeRemaining = formatTimeRemaining(estimatedRemaining);
const showConnectionLost = !isPolling && !isCompleted && !isUploading; const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return ( return (
<div className="flex flex-col items-center gap-6 py-4"> <div className="flex flex-col gap-0 overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{/* Animated spinner */}
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10"> {/* ═══ Header band ═══ */}
<Loader2 className="size-8 animate-spin text-primary" aria-hidden /> <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> </div>
{/* Status text */} <div className="flex flex-col gap-6 p-6">
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-base font-medium text-foreground"> {/* ═══ Pipeline Stepper ═══ */}
{currentStep || t('dashboard.translate.progress.processingFallback')} <div className="flex items-start justify-between">
</p> {PIPELINE_STEPS.map((step, i) => {
{timeRemaining && ( const isActive = i === activeIdx;
<p className="flex items-center gap-1.5 text-sm text-muted-foreground"> const isDone = i < activeIdx;
<Clock className="size-3.5" aria-hidden /> const Icon = step.icon;
{timeRemaining}
</p> 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&apos;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> </div>
</div>
{/* Progress bar + percentage */} );
<div className="w-full max-w-md space-y-2"> }
<Progress
value={progress} /* ── Stat card sub-component ────────────────────────────────────── */
animate={animate} function StatCard({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
className="h-3 rounded-full" return (
aria-label={t('dashboard.translate.progress.ariaProgress')} <div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
aria-valuenow={Math.round(progress)} <div className="text-primary">{icon}</div>
aria-valuemin={0} <p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
aria-valuemax={100} <p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
/>
<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> </div>
); );
} }

View File

@@ -1,106 +1,70 @@
'use client'; 'use client';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { import {
ShieldCheck, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2,
Clock, FileSpreadsheet, FileText, Presentation, Upload, X,
ArrowRight, Zap, Brain, CheckCircle2, ArrowLeftRight,
RotateCcw, Search, Languages, Wrench, Activity, Gauge, Timer,
Loader2, Download, Plus, TrendingUp, AlertTriangle, FileType,
FileSpreadsheet,
FileText,
Presentation,
Upload,
X,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileDropZone } from './FileDropZone'; import { FileDropZone } from './FileDropZone';
import { useFileUpload } from './useFileUpload'; import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig'; import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit'; import { useTranslationSubmit } from './useTranslationSubmit';
import { LanguageSelector } from './LanguageSelector'; import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector'; import { ProviderSelector } from './ProviderSelector';
import { TranslationProgress } from './TranslationProgress';
import { TranslationComplete } from './TranslationComplete';
import { useNotification } from '@/components/ui/notification'; import { useNotification } from '@/components/ui/notification';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { API_BASE } from '@/lib/config';
import { cn } from '@/lib/utils';
/* ── helpers ─────────────────────────────────────────────────────── */ /* ── helpers ─────────────────────────────────────────────────────── */
const FILE_ICONS: Record<string, React.ElementType> = { const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet, xlsx: FileSpreadsheet, docx: FileText, pptx: Presentation, pdf: FileType,
docx: FileText,
pptx: Presentation,
}; };
const FILE_COLORS: Record<string, string> = { const FILE_COLORS: Record<string, string> = {
xlsx: 'text-green-500', xlsx: 'text-green-500', docx: 'text-blue-500', pptx: 'text-orange-500', pdf: 'text-red-500',
docx: 'text-blue-500',
pptx: 'text-orange-500',
}; };
function fmt(bytes: number) { function fmt(bytes: number) {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`; return `${(bytes / 1048576).toFixed(1)} MB`;
} }
/* ── Compact file strip ──────────────────────────────────────────── */ /* ── Quality label based on provider ────────────────────────────── */
function FileStrip({ function getQualityLabel(t: (key: string) => string, provider: string | null | undefined): string {
file, if (!provider) return t('dashboard.translate.highQuality');
onRemove, if (['openai', 'openrouter', 'openrouter_premium'].includes(provider)) return t('dashboard.translate.highQuality');
onReplace, if (provider === 'deepl') return t('dashboard.translate.highQuality');
}: { return t('dashboard.translate.quality');
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>
);
} }
/* ── Trust row ───────────────────────────────────────────────────── */ /* ── Pipeline step keys ─────────────────────────────────────────── */
function TrustRow() { const PIPELINE_STEP_KEYS = [
const { t } = useI18n(); 'dashboard.translate.pipeline.upload',
return ( 'dashboard.translate.pipeline.analyze',
<div className="flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground"> 'dashboard.translate.pipeline.translate',
<span className="flex items-center gap-1.5"> 'dashboard.translate.pipeline.rebuild',
<ShieldCheck className="size-3.5" /> 'dashboard.translate.pipeline.finalize',
{t('dashboard.translate.trust.zeroRetention')} ] as const;
</span>
<span className="h-3 w-px bg-border" aria-hidden /> const PIPELINE_ICONS = [Upload, Search, Languages, Wrench, CheckCircle2] as const;
<span className="flex items-center gap-1.5"> const PIPELINE_STARTS = [0, 10, 25, 75, 92];
<Clock className="size-3.5" />
{t('dashboard.translate.trust.deletedAfter')} function getActiveStepIdx(progress: number) {
</span> for (let i = PIPELINE_STARTS.length - 1; i >= 0; i--) {
</div> 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 ────────────────────────────────────────────────────────── */ /* ── Page ────────────────────────────────────────────────────────── */
@@ -112,201 +76,528 @@ export default function TranslatePage() {
const { t } = useI18n(); const { t } = useI18n();
const lastErrorRef = useRef<string | null>(null); const lastErrorRef = useRef<string | null>(null);
const replaceInputRef = useRef<HTMLInputElement>(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(() => { useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) { if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error; lastErrorRef.current = submit.error;
showError({ showError({ title: t('dashboard.translate.errorNotificationTitle'), description: submit.error });
title: t('dashboard.translate.errorNotificationTitle'),
description: submit.error,
});
} }
}, [submit.error, showError, t]); }, [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 () => { const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return; 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 = () => { const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); };
submit.reset(); const handleDownload = async () => {
upload.removeFile(); 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 isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing = const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
(submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isCompleted = submit.status === 'completed'; const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed'; const isFailed = submit.status === 'failed';
/* ── STATE: No file ─────────────────────────────────────────────── */ const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
if (!upload.file && !isProcessing && !isCompleted && !isFailed) { const showConfiguring = isConfiguring;
return ( const showProcessing = isProcessing;
<div className="flex h-full flex-col gap-6 overflow-y-auto p-6 lg:p-8"> const showComplete = isCompleted && !!submit.jobId;
<div> const showFailed = isFailed;
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{t('dashboard.translate.pageTitle')} const currentProvider = config.availableProviders.find(p => p.id === config.provider);
</h1> const srcLangName = config.languages.find(l => l.code === config.sourceLang)?.name || config.sourceLang;
<p className="mt-1 text-sm text-muted-foreground"> const tgtLangName = config.languages.find(l => l.code === config.targetLang)?.name || config.targetLang;
{t('dashboard.translate.pageSubtitle')} const activeStepIdx = getActiveStepIdx(submit.progress);
</p> const qualityLabel = useMemo(() => getQualityLabel(t, config.provider), [t, config.provider]);
</div>
<div className="flex flex-1 flex-col"> /* ═══════════════════════════════════════════════════════════════ */
<FileDropZone upload={upload} /> /* UNIFIED LAYOUT — always the same grid */
{upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>} /* ═══════════════════════════════════════════════════════════════ */
</div> return (
<TrustRow /> <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> </div>
);
}
/* ── STATE: Configure — single column, full width ───────────────── */ {/* Grid: LEFT (2/3) + RIGHT (1/3) — always present */}
if (isConfiguring) { <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
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>
{/* File strip */} {/* ═══════════════════════════════════════════════════════ */}
<FileStrip {/* LEFT CARD (2/3) — content swaps based on state */}
file={upload.file!} {/* ═══════════════════════════════════════════════════════ */}
onRemove={upload.removeFile} <div className="lg:col-span-2 flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm overflow-hidden">
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>}
{/* Language selector — full width */} {/* ── UPLOAD STATE: Dropzone ─────────────────────────── */}
<LanguageSelector {showUpload && (
sourceLang={config.sourceLang} <>
targetLang={config.targetLang} <div className="px-6 pt-6 pb-4">
languages={config.languages} <h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
isLoading={config.isLoadingLanguages} </div>
error={config.languagesError} <div className="flex-1 px-6 pb-6">
onSourceChange={config.setSourceLang} <FileDropZone upload={upload} />
onTargetChange={config.setTargetLang} {upload.error && <p className="mt-2 text-sm text-destructive">{upload.error}</p>}
/> </div>
</>
)}
{/* Provider selector — full width */} {/* ── CONFIGURING STATE: File strip ──────────────────── */}
<ProviderSelector {showConfiguring && (
provider={config.provider} <>
onProviderChange={config.setProvider} <div className="px-6 pt-6 pb-4">
availableProviders={config.availableProviders} <h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.sourceDocument')}</h2>
isLoadingProviders={config.isLoadingProviders} </div>
isPro={config.isPro} <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> </div>
{/* Sticky bottom bar: button + trust */} {/* ═══════════════════════════════════════════════════════ */}
<div className="shrink-0 border-t border-border/50 bg-background px-6 py-4 lg:px-8"> {/* RIGHT CARD (1/3) — Config / Monitor / Summary */}
<Button {/* ═══════════════════════════════════════════════════════ */}
size="lg" <div className="flex flex-col gap-0 rounded-2xl border border-border bg-card shadow-sm">
className="h-13 w-full gap-2 text-base font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting} {/* ── CONFIG (upload / configuring / failed) ──────────── */}
onClick={handleTranslate} {(showUpload || showConfiguring || showFailed) && (
> <>
{submit.isSubmitting ? ( <div className="px-6 pt-6 pb-4">
<> <h2 className="text-lg font-semibold text-foreground">{t('dashboard.translate.configuration')}</h2>
<Loader2 className="size-5 animate-spin" /> </div>
{t('dashboard.translate.actions.uploading')}
</> <div className="flex flex-1 flex-col gap-5 px-6">
) : ( <LanguageSelector
<> sourceLang={config.sourceLang} targetLang={config.targetLang}
{t('dashboard.translate.actions.translate')} languages={config.languages} isLoading={config.isLoadingLanguages}
<ArrowRight className="size-5 rtl:rotate-180" /> error={config.languagesError} onSourceChange={config.setSourceLang}
</> onTargetChange={config.setTargetLang}
)} />
</Button>
<div className="mt-3"> <ProviderSelector
<TrustRow /> provider={config.provider} onProviderChange={config.setProvider}
</div> 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>
</div> </div>
); </div>
} );
}
/* ── STATE: Processing ──────────────────────────────────────────── */
if (isProcessing) { /* ═══ Sub-components ═══════════════════════════════════════════════ */
return (
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6"> /** Compact file strip */
{(submit.fileName || upload.file?.name) && ( function FileStrip({ file, onRemove, onReplace, t }: { file: File; onRemove: () => void; onReplace: () => void; t: (key: string) => string }) {
<p className="text-sm text-muted-foreground"> const ext = file.name.split('.').pop()?.toLowerCase() ?? '';
{t('dashboard.translate.actions.filePrefix')}{' '} const FileIcon = FILE_ICONS[ext] ?? FileText;
<span className="font-medium text-foreground"> const color = FILE_COLORS[ext] ?? 'text-muted-foreground';
{submit.fileName || upload.file?.name} return (
</span> <div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
</p> <FileIcon className={`size-5 shrink-0 ${color}`} />
)} <div className="flex min-w-0 flex-1 flex-col">
<div className="w-full max-w-md"> <span className="truncate text-sm font-semibold text-foreground">{file.name}</span>
<TranslationProgress <span className="text-xs text-muted-foreground">{fmt(file.size)} · .{ext.toUpperCase()}</span>
progress={submit.progress} </div>
currentStep={ <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">
submit.currentStep || <Upload className="size-3.5" />{t('dashboard.translate.replace')}
(submit.isSubmitting </button>
? t('dashboard.translate.steps.uploading') <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">
: t('dashboard.translate.steps.starting')) <X className="size-4" />
} </button>
estimatedRemaining={submit.estimatedRemaining} </div>
error={null} );
isPolling={submit.isPolling} }
isUploading={submit.isSubmitting}
isCompleted={false} /** Pipeline stepper */
/> function PipelineStepper({ activeIdx, t }: { activeIdx: number; t: (key: string) => string }) {
</div> return (
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2"> <div className="flex items-start justify-between">
<RotateCcw className="size-4" /> {PIPELINE_STEP_KEYS.map((stepKey, i) => {
{t('dashboard.translate.actions.cancel')} const isActive = i === activeIdx;
</Button> const isDone = i < activeIdx;
</div> 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">
/* ── STATE: Complete ────────────────────────────────────────────── */ <div className={cn(
if (isCompleted && submit.jobId) { 'flex size-9 shrink-0 items-center justify-center rounded-full transition-all duration-500',
return ( isDone
<div className="flex h-full items-center justify-center overflow-y-auto p-6"> ? 'bg-primary text-primary-foreground shadow-md shadow-primary/20'
<TranslationComplete : isActive
jobId={submit.jobId} ? 'bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-[3px] ring-primary/20'
fileName={submit.fileName} : 'bg-muted text-muted-foreground',
onNewTranslation={handleNewTranslation} )}>
/> {isDone ? <CheckCircle2 className="size-4" /> : <Icon className={cn('size-4', isActive && 'animate-pulse')} />}
</div> </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')} />
)}
/* ── STATE: Failed ──────────────────────────────────────────────── */ </div>
if (isFailed) { <span className={cn('text-[11px] font-medium transition-colors', isDone || isActive ? 'text-primary' : 'text-muted-foreground')}>
return ( {t(stepKey)}
<div className="flex h-full flex-col items-center justify-center gap-6 overflow-y-auto p-6"> </span>
<div className="w-full max-w-md"> </div>
<TranslationProgress );
progress={submit.progress} })}
currentStep={submit.currentStep} </div>
estimatedRemaining={submit.estimatedRemaining} );
error={submit.error} }
isPolling={false}
isCompleted={false} /** Small stat box */
/> function StatBox({ icon, value, label }: { icon: React.ReactNode; value: string; label: string }) {
</div> return (
<Button variant="outline" size="lg" onClick={handleNewTranslation} className="gap-2"> <div className="flex flex-col items-center gap-1 rounded-xl border border-border bg-muted/20 p-2.5 text-center">
<RotateCcw className="size-4" /> <div className="text-primary">{icon}</div>
{t('dashboard.translate.actions.tryAgain')} <p className="text-sm font-bold tabular-nums text-foreground leading-none">{value}</p>
</Button> <p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">{label}</p>
</div> </div>
); );
}
return null;
} }

View File

@@ -41,6 +41,7 @@ export interface TranslationConfig {
targetLang: string; targetLang: string;
mode: TranslationMode; mode: TranslationMode;
provider?: Provider; provider?: Provider;
pdfMode?: 'layout' | 'text_only';
} }
export interface UseTranslationConfigReturn { export interface UseTranslationConfigReturn {

View File

@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { UseFileUploadReturn } from './types'; 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; const MAX_FILE_SIZE = 50 * 1024 * 1024;
export const ERROR_MESSAGES = { 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)', FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
} as const; } as const;

View File

@@ -10,6 +10,7 @@ import type {
AvailableProvider, AvailableProvider,
} from './types'; } from './types';
import { API_BASE } from '@/lib/config'; import { API_BASE } from '@/lib/config';
import { useTranslationStore } from '@/lib/store';
/** Fallback when API fails — Google is always available server-side */ /** Fallback when API fails — Google is always available server-side */
const FALLBACK_PROVIDERS: AvailableProvider[] = [ const FALLBACK_PROVIDERS: AvailableProvider[] = [
@@ -59,8 +60,9 @@ const FALLBACK_LANGUAGES: Language[] = [
]; ];
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn { export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
const { settings } = useTranslationStore();
const [sourceLang, setSourceLang] = useState('auto'); const [sourceLang, setSourceLang] = useState('auto');
const [targetLang, setTargetLang] = useState(''); const [targetLang, setTargetLang] = useState(settings.defaultTargetLanguage || '');
const [provider, setProvider] = useState<Provider | null>(null); const [provider, setProvider] = useState<Provider | null>(null);
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]); const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
const [isLoadingProviders, setIsLoadingProviders] = useState(false); const [isLoadingProviders, setIsLoadingProviders] = useState(false);
@@ -69,6 +71,13 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false); const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
const [languagesError, setLanguagesError] = useState<string | null>(null); 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 // Fetch available (admin-configured) providers
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -105,6 +114,16 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
return () => { controller.abort(); clearTimeout(timeoutId); }; 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 // Fetch supported languages
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -148,11 +167,12 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
// Check user tier // Check user tier
useEffect(() => { useEffect(() => {
const checkTier = async () => { const checkTier = async () => {
const isProTier = (u: any) => ['pro', 'business', 'enterprise'].includes(u?.plan ?? u?.tier ?? '');
const userStr = localStorage.getItem('user'); const userStr = localStorage.getItem('user');
if (userStr) { if (userStr) {
try { try {
const user = JSON.parse(userStr); const user = JSON.parse(userStr);
if (user.tier) { setIsPro(user.tier === 'pro'); return; } if (user?.plan || user?.tier) { setIsPro(isProTier(user)); return; }
} catch { /* continue */ } } catch { /* continue */ }
} }
try { try {
@@ -164,7 +184,7 @@ export function useTranslationConfig(hasFile: boolean): UseTranslationConfigRetu
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
const user = result.data; const user = result.data;
setIsPro(user.tier === 'pro'); setIsPro(isProTier(user));
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));
} else { } else {
setIsPro(false); setIsPro(false);

View File

@@ -62,6 +62,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
setError('Translation job not found'); setError('Translation job not found');
return; 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}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@@ -135,6 +139,10 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
if (config.mode === 'llm' && config.provider) { if (config.mode === 'llm' && config.provider) {
formData.append('provider', 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 token = localStorage.getItem('token');
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};

View File

@@ -11,7 +11,7 @@ export function useUser(): UseQueryResult<User, ApiClientError> {
return useQuery({ return useQuery({
queryKey: ['user', 'me'], queryKey: ['user', 'me'],
queryFn: async (): Promise<User> => { 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; return response.data;
}, },
retry: (failureCount, error) => { retry: (failureCount, error) => {

View File

@@ -24,7 +24,7 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en" suppressHydrationWarning> <html suppressHydrationWarning>
<body className={`${inter.className} bg-background text-foreground antialiased`}> <body className={`${inter.className} bg-background text-foreground antialiased`}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}> <ThemeProvider attribute="class" defaultTheme="system" enableSystem={true} disableTransitionOnChange={false}>
<I18nProvider> <I18nProvider>

View File

@@ -15,6 +15,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { API_BASE } from "@/lib/config"; import { API_BASE } from "@/lib/config";
import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing"; import { ANNUAL_DISCOUNT_PERCENT } from "@/lib/pricing";
import { useI18n } from "@/lib/i18n";
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Types Types
@@ -50,11 +51,13 @@ interface CreditPackage {
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Static plan data (fallback + SSR-friendly) Static plan data (fallback + SSR-friendly)
Names, descriptions, features, badges use i18n keys
resolved via t() in the render layer.
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
const STATIC_PLANS: Plan[] = [ const STATIC_PLANS: Plan[] = [
{ {
id: "free", id: "free",
name: "Gratuit", name: "pricing.plans.free.name",
price_monthly: 0, price_monthly: 0,
price_yearly: 0, price_yearly: 0,
docs_per_month: 5, docs_per_month: 5,
@@ -63,21 +66,21 @@ const STATIC_PLANS: Plan[] = [
max_chars_per_month: 50_000, max_chars_per_month: 50_000,
providers: ["google"], providers: ["google"],
features: [ features: [
"5 documents / mois", "pricing.plans.free.feat1",
"Jusqu'à 15 pages par document", "pricing.plans.free.feat2",
"Google Traduction inclus", "pricing.plans.free.feat3",
"Toutes les langues (130+)", "pricing.plans.free.feat4",
"Support communautaire", "pricing.plans.free.feat5",
], ],
ai_translation: false, ai_translation: false,
api_access: false, api_access: false,
priority_processing: false, priority_processing: false,
popular: false, popular: false,
description: "Parfait pour découvrir l'application", description: "pricing.plans.free.description",
}, },
{ {
id: "starter", id: "starter",
name: "Starter", name: "pricing.plans.starter.name",
price_monthly: 9.00, price_monthly: 9.00,
price_yearly: 86.40, price_yearly: 86.40,
docs_per_month: 50, docs_per_month: 50,
@@ -86,22 +89,22 @@ const STATIC_PLANS: Plan[] = [
max_chars_per_month: 500_000, max_chars_per_month: 500_000,
providers: ["google", "deepl"], providers: ["google", "deepl"],
features: [ features: [
"50 documents / mois", "pricing.plans.starter.feat1",
"Jusqu'à 50 pages par document", "pricing.plans.starter.feat2",
"Google Traduction + DeepL", "pricing.plans.starter.feat3",
"Fichiers jusqu'à 10 Mo", "pricing.plans.starter.feat4",
"Support par e-mail", "pricing.plans.starter.feat5",
"Historique 30 jours", "pricing.plans.starter.feat6",
], ],
ai_translation: false, ai_translation: false,
api_access: false, api_access: false,
priority_processing: false, priority_processing: false,
popular: false, popular: false,
description: "Pour les particuliers et petits projets", description: "pricing.plans.starter.description",
}, },
{ {
id: "pro", id: "pro",
name: "Pro", name: "pricing.plans.pro.name",
price_monthly: 19.00, price_monthly: 19.00,
price_yearly: 182.40, price_yearly: 182.40,
docs_per_month: 200, docs_per_month: 200,
@@ -110,27 +113,27 @@ const STATIC_PLANS: Plan[] = [
max_chars_per_month: 2_000_000, max_chars_per_month: 2_000_000,
providers: ["google", "deepl", "openrouter"], providers: ["google", "deepl", "openrouter"],
features: [ features: [
"200 documents / mois", "pricing.plans.pro.feat1",
"Jusqu'à 200 pages par document", "pricing.plans.pro.feat2",
"Traduction IA Essentielle (DeepSeek V3.2)", "pricing.plans.pro.feat3",
"Google Traduction + DeepL", "pricing.plans.pro.feat4",
"Fichiers jusqu'à 25 Mo", "pricing.plans.pro.feat5",
"Glossaires personnalisés", "pricing.plans.pro.feat6",
"Support prioritaire", "pricing.plans.pro.feat7",
"Historique 90 jours", "pricing.plans.pro.feat8",
], ],
ai_translation: true, ai_translation: true,
ai_tier: "essential", ai_tier: "essential",
api_access: false, api_access: false,
priority_processing: true, priority_processing: true,
popular: true, popular: true,
highlight: "Le plus populaire", highlight: "pricing.plans.pro.highlight",
description: "Pour les professionnels et équipes en croissance", description: "pricing.plans.pro.description",
badge: "POPULAIRE", badge: "pricing.plans.pro.badge",
}, },
{ {
id: "business", id: "business",
name: "Business", name: "pricing.plans.business.name",
price_monthly: 49.00, price_monthly: 49.00,
price_yearly: 470.40, price_yearly: 470.40,
docs_per_month: 1000, docs_per_month: 1000,
@@ -139,16 +142,16 @@ const STATIC_PLANS: Plan[] = [
max_chars_per_month: 10_000_000, max_chars_per_month: 10_000_000,
providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"], providers: ["google", "deepl", "openrouter", "openrouter_premium", "openai"],
features: [ features: [
"1 000 documents / mois", "pricing.plans.business.feat1",
"Jusqu'à 500 pages par document", "pricing.plans.business.feat2",
"IA Essentielle + Premium (Claude Haiku)", "pricing.plans.business.feat3",
"Tous les fournisseurs de traduction", "pricing.plans.business.feat4",
"Fichiers jusqu'à 50 Mo", "pricing.plans.business.feat5",
"Accès API (10 000 appels/mois)", "pricing.plans.business.feat6",
"Webhooks de notification", "pricing.plans.business.feat7",
"Support dédié", "pricing.plans.business.feat8",
"Historique 1 an", "pricing.plans.business.feat9",
"Analytiques avancées", "pricing.plans.business.feat10",
], ],
ai_translation: true, ai_translation: true,
ai_tier: "premium", ai_tier: "premium",
@@ -156,11 +159,11 @@ const STATIC_PLANS: Plan[] = [
priority_processing: true, priority_processing: true,
team_seats: 5, team_seats: 5,
popular: false, popular: false,
description: "Pour les équipes et organisations", description: "pricing.plans.business.description",
}, },
{ {
id: "enterprise", id: "enterprise",
name: "Entreprise", name: "pricing.plans.enterprise.name",
price_monthly: -1, price_monthly: -1,
price_yearly: -1, price_yearly: -1,
docs_per_month: -1, docs_per_month: -1,
@@ -169,14 +172,14 @@ const STATIC_PLANS: Plan[] = [
max_chars_per_month: -1, max_chars_per_month: -1,
providers: ["all"], providers: ["all"],
features: [ features: [
"Documents illimités", "pricing.plans.enterprise.feat1",
"Tous les modèles IA (GPT-5, Claude Opus 4.6…)", "pricing.plans.enterprise.feat2",
"Déploiement on-premise ou cloud dédié", "pricing.plans.enterprise.feat3",
"SLA 99,9 % garanti", "pricing.plans.enterprise.feat4",
"Support 24/7 dédié", "pricing.plans.enterprise.feat5",
"Marque blanche (white-label)", "pricing.plans.enterprise.feat6",
"Équipes illimitées", "pricing.plans.enterprise.feat7",
"Intégrations sur mesure", "pricing.plans.enterprise.feat8",
], ],
ai_translation: true, ai_translation: true,
ai_tier: "custom", ai_tier: "custom",
@@ -184,8 +187,8 @@ const STATIC_PLANS: Plan[] = [
priority_processing: true, priority_processing: true,
team_seats: -1, team_seats: -1,
popular: false, popular: false,
description: "Solutions sur mesure pour grandes organisations", description: "pricing.plans.enterprise.description",
badge: "SUR DEVIS", 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" }, 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() { function PricingDataSkeleton() {
return ( return (
<> <>
@@ -257,40 +260,20 @@ function PricingDataSkeleton() {
} }
const FAQS = [ const FAQS = [
{ { q: "pricing.faq.q1", a: "pricing.faq.a1" },
q: "Puis-je changer de forfait à tout moment ?", { q: "pricing.faq.q2", a: "pricing.faq.a2" },
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: "pricing.faq.q3", a: "pricing.faq.a3" },
}, { q: "pricing.faq.q4", a: "pricing.faq.a4" },
{ { q: "pricing.faq.q5", a: "pricing.faq.a5" },
q: "Qu'est-ce que la « Traduction IA Essentielle » ?", { q: "pricing.faq.q6", a: "pricing.faq.a6" },
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: "pricing.faq.q7", a: "pricing.faq.a7" },
},
{
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.",
},
]; ];
/* ───────────────────────────────────────────── /* ─────────────────────────────────────────────
Main component Main component
───────────────────────────────────────────── */ ───────────────────────────────────────────── */
export default function PricingPage() { export default function PricingPage() {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const planFromUrl = searchParams.get("plan"); const planFromUrl = searchParams.get("plan");
@@ -303,11 +286,11 @@ export default function PricingPage() {
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null); const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [toastMsg, setToastMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT); const [annualDiscountPercent, setAnnualDiscountPercent] = useState(ANNUAL_DISCOUNT_PERCENT);
/** Tant que false : on naffiche 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); const [pricingLoaded, setPricingLoaded] = useState(false);
useEffect(() => { 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`; const plansUrl = `${API_BASE}/api/v1/auth/plans`;
fetch(plansUrl, { cache: "no-store" }) fetch(plansUrl, { cache: "no-store" })
.then(async (r) => { .then(async (r) => {
@@ -327,7 +310,7 @@ export default function PricingPage() {
}) })
.catch((err) => { .catch((err) => {
if (process.env.NODE_ENV === "development") { 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(() => { .finally(() => {
@@ -354,7 +337,7 @@ export default function PricingPage() {
if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) { if (planFromUrl && isLoggedIn && plans.length > 0 && currentPlan !== null) {
const targetPlan = plans.find(p => p.id === planFromUrl); const targetPlan = plans.find(p => p.id === planFromUrl);
if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) { if (targetPlan && targetPlan.price_monthly > 0 && currentPlan !== planFromUrl) {
// Directly initiate checkout — no more redirect to /settings/subscription // Directly initiate checkout
handleSubscribe(planFromUrl); handleSubscribe(planFromUrl);
} }
} }
@@ -399,7 +382,7 @@ export default function PricingPage() {
const backendMessage = data.message ?? data.error ?? data.data?.error; const backendMessage = data.message ?? data.error ?? data.data?.error;
if (!res.ok) { 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) { if (url) {
@@ -408,11 +391,11 @@ export default function PricingPage() {
// Demo mode: Stripe not yet configured // Demo mode: Stripe not yet configured
setToastMsg({ setToastMsg({
type: 'ok', 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) { } 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 }); setToastMsg({ type: 'err', text: message });
} finally { } finally {
setLoadingPlanId(null); 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" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Retour {t('pricing.nav.back')}
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href={isLoggedIn ? "/dashboard" : "/"} href={isLoggedIn ? "/dashboard" : "/"}
className="px-3 py-1.5 rounded-lg text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground transition-colors" 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> </Link>
{isLoggedIn && ( {isLoggedIn && (
<Link <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" 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> </Link>
)} )}
</div> </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="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"> <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" /> <Cpu className="w-4 h-4" />
Modèles IA mis à jour Mars 2026 {t('pricing.header.badge')}
</div> </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"> <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> </h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
Traduisez vos documents Word, Excel et PowerPoint en conservant {t('pricing.header.subtitle')}
la mise en page originale. Sans jamais saisir de clé API.
</p> </p>
{/* Monthly / Yearly toggle */} {/* Monthly / Yearly toggle */}
@@ -487,7 +469,7 @@ export default function PricingPage() {
!isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground" !isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground"
)} )}
> >
Mensuel {t('pricing.billing.monthly')}
</button> </button>
<button <button
onClick={() => setIsYearly(true)} onClick={() => setIsYearly(true)}
@@ -496,7 +478,7 @@ export default function PricingPage() {
isYearly ? "bg-foreground text-background shadow" : "text-muted-foreground hover:text-foreground" 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"> <span className="bg-success text-success-foreground text-xs px-1.5 py-0.5 rounded-full">
{annualDiscountPercent} % {annualDiscountPercent} %
</span> </span>
@@ -504,7 +486,7 @@ export default function PricingPage() {
</div> </div>
</div> </div>
{/* ── Plan cards (+ comparaison + crédits) : squelette jusquà lAPI 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"> <div className="max-w-7xl mx-auto px-4 pb-20">
{!pricingLoaded ? ( {!pricingLoaded ? (
<PricingDataSkeleton /> <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", "absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold text-white",
colors.badge colors.badge
)}> )}>
{plan.badge} {t(plan.badge)}
</div> </div>
)} )}
{isCurrent && ( {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"> <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> </div>
)} )}
@@ -550,27 +532,27 @@ export default function PricingPage() {
<div className="p-1.5 bg-white/10 rounded-lg"> <div className="p-1.5 bg-white/10 rounded-lg">
<Icon className="w-5 h-5 text-white" /> <Icon className="w-5 h-5 text-white" />
</div> </div>
<span className="font-bold text-white">{plan.name}</span> <span className="font-bold text-white">{t(plan.name)}</span>
</div> </div>
{isEnterprise ? ( {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 ? ( ) : 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"> <div className="flex items-end gap-1">
<span className="text-3xl font-bold text-white">{price} </span> <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> </div>
)} )}
{isYearly && plan.price_yearly > 0 && ( {isYearly && plan.price_yearly > 0 && (
<div className="text-white/70 text-xs mt-1"> <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> </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> </div>
{/* Features */} {/* Features */}
@@ -579,21 +561,21 @@ export default function PricingPage() {
<div className="grid grid-cols-1 gap-2 mb-3"> <div className="grid grid-cols-1 gap-2 mb-3">
<Stat <Stat
icon={<FileText className="w-3.5 h-3.5" />} icon={<FileText className="w-3.5 h-3.5" />}
label="Documents" label={t('pricing.card.documents')}
value={plan.docs_per_month === -1 ? "Illimité" : `${plan.docs_per_month} / mois`} value={plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
/> />
<Stat <Stat
icon={<Layers className="w-3.5 h-3.5" />} icon={<Layers className="w-3.5 h-3.5" />}
label="Pages max" label={t('pricing.card.pagesMax')}
value={plan.max_pages_per_doc === -1 ? "Illimité" : `${plan.max_pages_per_doc} p / doc`} value={plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
/> />
{plan.ai_translation && ( {plan.ai_translation && (
<Stat <Stat
icon={<Brain className="w-3.5 h-3.5 text-violet-400" />} icon={<Brain className="w-3.5 h-3.5 text-violet-400" />}
label="Traduction IA" label={t('pricing.card.aiTranslation')}
value={ value={
plan.ai_tier === "essential" ? "Essentielle" : plan.ai_tier === "essential" ? t('pricing.card.aiEssential') :
plan.ai_tier === "premium" ? "Essentielle + Premium" : "Sur mesure" plan.ai_tier === "premium" ? t('pricing.card.aiEssentialPremium') : t('pricing.card.aiCustom')
} }
highlight highlight
/> />
@@ -604,7 +586,7 @@ export default function PricingPage() {
{plan.features.map((feat, i) => ( {plan.features.map((feat, i) => (
<div key={i} className="flex items-start gap-2"> <div key={i} className="flex items-start gap-2">
<Check className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" /> <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>
))} ))}
</div> </div>
@@ -616,9 +598,9 @@ export default function PricingPage() {
<Button <Button
variant="outline" variant="outline"
className="w-full border-success/50 text-success hover:bg-success/10" 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> </Button>
) : plan.id === "free" && !currentPlan ? ( ) : plan.id === "free" && !currentPlan ? (
<Button <Button
@@ -626,7 +608,7 @@ export default function PricingPage() {
className="w-full border-border text-muted-foreground hover:bg-muted/30" className="w-full border-border text-muted-foreground hover:bg-muted/30"
onClick={() => router.push("/auth/register")} onClick={() => router.push("/auth/register")}
> >
Commencer gratuitement {t('pricing.card.startFree')}
</Button> </Button>
) : ( ) : (
<button <button
@@ -644,11 +626,11 @@ export default function PricingPage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <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" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg> </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" /> <ArrowRight className="w-4 h-4" />
</> </>
)} )}
@@ -662,33 +644,33 @@ export default function PricingPage() {
{/* ── Feature comparison table ── */} {/* ── Feature comparison table ── */}
<div className="mt-20"> <div className="mt-20">
<h2 className="text-3xl font-bold text-center mb-2">Comparaison détaillée</h2> <h2 className="text-3xl font-bold text-center mb-2">{t('pricing.comparison.title')}</h2>
<p className="text-muted-foreground text-center mb-10">Tout ce qui est inclus dans chaque forfait</p> <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"> <div className="overflow-x-auto rounded-2xl border border-border/40">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="bg-muted/60 border-b border-border/40"> <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) => ( {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")}> <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> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{[ {[
{ label: "Documents / mois", vals: plans.slice(0,4).map(p => p.docs_per_month === -1 ? "∞" : String(p.docs_per_month)) }, { label: t('pricing.comparison.docsPerMonth'), 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: t('pricing.comparison.pagesMaxPerDoc'), 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: 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: "Google Traduction", vals: plans.slice(0,4).map(() => true) }, { label: t('pricing.comparison.googleTranslation'), 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: t('pricing.comparison.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: 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: "Traduction IA Premium", vals: plans.slice(0,4).map(p => p.ai_translation && (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: "Accès API", vals: plans.slice(0,4).map(p => p.api_access) }, { label: t('pricing.comparison.apiAccess'), vals: plans.slice(0,4).map(p => p.api_access) },
{ label: "Traitement prioritaire", vals: plans.slice(0,4).map(p => p.priority_processing) }, { label: t('pricing.comparison.priorityProcessing'), vals: plans.slice(0,4).map(p => p.priority_processing) },
{ label: "Support", vals: ["Communauté", "E-mail", "Prioritaire", "Dédié"] }, { 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) => ( ].map((row, i) => (
<tr key={i} className={cn("border-b border-border/20", i % 2 === 0 ? "bg-muted/20" : "")}> <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> <td className="py-3 px-6 text-muted-foreground">{row.label}</td>
@@ -714,10 +696,10 @@ export default function PricingPage() {
{/* ── Credits ── */} {/* ── Credits ── */}
<div className="mt-20"> <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"> <p className="text-muted-foreground text-center mb-8">
Besoin de plus ? Achetez des crédits à l'unité, sans abonnement. {t('pricing.credits.subtitle')}
<span className="text-muted-foreground/70"> 1 crédit = 1 page traduite.</span> <span className="text-muted-foreground/70"> {t('pricing.credits.perPage')}</span>
</p> </p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-3xl mx-auto">
{credits.map((pkg, i) => ( {credits.map((pkg, i) => (
@@ -732,15 +714,15 @@ export default function PricingPage() {
> >
{pkg.popular && ( {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"> <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>
)} )}
<div className="text-2xl font-bold text-foreground">{pkg.credits}</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-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"> <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> </button>
</div> </div>
))} ))}
@@ -752,15 +734,15 @@ export default function PricingPage() {
{/* ── Trust signals ── */} {/* ── Trust signals ── */}
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-6"> <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: <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: "130+ langues", sub: "Dont arabe, persan, hébreu (RTL)" }, { 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: "Traitement parallèle", sub: "IA multi-thread ultra-rapide" }, { 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: "Disponible 24/7", sub: "Uptime garanti 99,9 %" }, { icon: <Clock className="w-6 h-6 text-amber-500" />, title: t('pricing.trust.availability.title'), sub: t('pricing.trust.availability.sub') },
].map((t, i) => ( ].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 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="p-3 rounded-full bg-muted/50 mb-3">{signal.icon}</div>
<div className="font-semibold text-foreground text-sm mb-1">{t.title}</div> <div className="font-semibold text-foreground text-sm mb-1">{signal.title}</div>
<div className="text-muted-foreground text-xs">{t.sub}</div> <div className="text-muted-foreground text-xs">{signal.sub}</div>
</div> </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="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"> <div className="flex items-center gap-3 mb-6">
<Brain className="w-6 h-6 text-accent" /> <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>
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div className="p-5 rounded-xl bg-card border border-border/40"> <div className="p-5 rounded-xl bg-card border border-border/40">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 text-blue-500" /> <Zap className="w-4 h-4 text-blue-500" />
<span className="font-semibold">Traduction IA Essentielle</span> <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">Forfait Pro</Badge> <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>
<div className="text-sm text-muted-foreground mb-3"> <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. {t('pricing.aiModels.essential.descPrefix')} <strong className="text-foreground">DeepSeek V3.2</strong> {t('pricing.aiModels.essential.descSuffix')}
Qualité comparable aux modèles frontier à 1/50ème du coût.
</div> </div>
<div className="flex flex-wrap gap-2 text-xs"> <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-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> </div>
<div className="p-5 rounded-xl bg-card border border-accent/30"> <div className="p-5 rounded-xl bg-card border border-accent/30">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Crown className="w-4 h-4 text-accent" /> <Crown className="w-4 h-4 text-accent" />
<span className="font-semibold">Traduction IA Premium</span> <span className="font-semibold">{t('pricing.aiModels.premium.title')}</span>
<Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">Forfait Business</Badge> <Badge className="ml-auto text-xs bg-accent/10 text-accent border-accent/30">{t('pricing.aiModels.premium.plan')}</Badge>
</div> </div>
<div className="text-sm text-muted-foreground mb-3"> <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 {t('pricing.aiModels.premium.descPrefix')} <strong className="text-foreground">Claude 3.5 Haiku</strong> {t('pricing.aiModels.premium.descSuffix')}
juridiques, médicaux et techniques complexes.
</div> </div>
<div className="flex flex-wrap gap-2 text-xs"> <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-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> </div>
</div> </div>
@@ -809,7 +789,7 @@ export default function PricingPage() {
{/* ── FAQ ── */} {/* ── FAQ ── */}
<div className="mt-20 max-w-3xl mx-auto"> <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"> <div className="space-y-3">
{FAQS.map((faq, i) => ( {FAQS.map((faq, i) => (
<div <div
@@ -817,10 +797,10 @@ export default function PricingPage() {
className="rounded-xl border border-border/40 bg-card overflow-hidden" className="rounded-xl border border-border/40 bg-card overflow-hidden"
> >
<button <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)} 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 {openFAQ === i
? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" /> ? <ChevronUp className="w-5 h-5 text-muted-foreground flex-shrink-0" />
: <ChevronDown 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> </button>
{openFAQ === i && ( {openFAQ === i && (
<div className="px-5 pb-5 text-muted-foreground text-sm leading-relaxed border-t border-border/30 pt-4"> <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>
)} )}
</div> </div>
@@ -838,20 +818,20 @@ export default function PricingPage() {
{/* ── CTA bottom ── */} {/* ── 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"> <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"> <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> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link href="/auth/register"> <Link href="/auth/register">
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-3 text-base font-semibold"> <Button className="bg-accent hover:bg-accent/90 text-accent-foreground px-8 py-3 text-base font-semibold">
Créer un compte gratuit {t('pricing.cta.createAccount')}
<ArrowRight className="ml-2 w-5 h-5" /> <ArrowRight className="ms-2 w-5 h-5" />
</Button> </Button>
</Link> </Link>
<Link href="/auth/login"> <Link href="/auth/login">
<Button variant="outline" className="px-8 py-3 text-base"> <Button variant="outline" className="px-8 py-3 text-base">
Se connecter {t('pricing.cta.login')}
</Button> </Button>
</Link> </Link>
</div> </div>

View File

@@ -567,12 +567,12 @@ export function FileUploader() {
> >
{isTranslating ? ( {isTranslating ? (
<> <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> <Loader2 className="me-2 h-5 w-5 animate-spin" />
Translating... 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 Translate Document
</> </>
)} )}
@@ -630,7 +630,7 @@ export function FileUploader() {
size="lg" size="lg"
className="group px-8" 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 Download Translated Document
</Button> </Button>
</CardContent> </CardContent>

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ export function HeroWordComparison() {
</div> </div>
<div className="relative p-2 sm:p-4 bg-[#1e1e1e]"> <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")} {t("landing.heroDoc.caption")}
</p> </p>
<div className="flex rounded-lg overflow-hidden border border-[#3c3c3c] shadow-inner min-h-[240px] sm:min-h-[300px]"> <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")} {t("landing.heroDoc.badgeOriginal")}
</span> </span>
</div> </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 <article
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]" className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
style={{ style={{
@@ -108,7 +108,7 @@ export function HeroWordComparison() {
{t("landing.heroDoc.badgeTranslated")} {t("landing.heroDoc.badgeTranslated")}
</span> </span>
</div> </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 <article
className="text-[11px] sm:text-xs leading-relaxed text-[#242424]" className="text-[11px] sm:text-xs leading-relaxed text-[#242424]"
style={{ style={{

View File

@@ -1,226 +1,355 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "@/lib/i18n";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "@/components/ui/language-switcher"; import { LanguageSwitcher } from "@/components/ui/language-switcher";
import { ThemeToggle } from "@/components/ui/theme-toggle"; 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 { import {
Table2,
FileText, FileText,
Table2,
Presentation, Presentation,
Bot, FileSpreadsheet,
Lock,
Zap,
Check, Check,
PlayCircle,
ShieldCheck, ShieldCheck,
Clock, Clock,
Languages, Languages,
Zap,
Sparkles,
ArrowRight,
BadgeCheck,
Eye,
LayoutGrid,
Bot,
Brain,
BookOpen,
Upload,
ArrowLeftRight,
} from "lucide-react"; } from "lucide-react";
export function LandingPage() { export function LandingPage() {
const { t } = useTranslation();
const [isYearly, setIsYearly] = useState(false); 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 ( return (
<div className="min-h-screen bg-background text-foreground font-sans"> <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"> <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="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"> <div className="flex items-center justify-center rounded-lg bg-primary/10 p-1.5">
<Languages className="h-5 w-5 text-primary" /> <Languages className="h-5 w-5 text-primary" />
</div> </div>
<span className="text-foreground text-lg font-bold tracking-tight"> <span className="text-foreground text-lg font-bold tracking-tight">
Office Translator Office Translator
</span> </span>
</div> </Link>
<nav className="hidden md:flex items-center gap-8"> <nav className="hidden md:flex items-center gap-8">
<a <a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#why">
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" {t("landing.nav.whyUs")}
href="#features"
>
{t("nav.features")}
</a> </a>
<a <a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#formats">
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" {t("landing.nav.formats")}
href="#pricing"
>
{t("nav.pricing")}
</a> </a>
<a <a className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" href="#pricing">
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors" {t("landing.nav.pricing")}
href="#enterprise"
>
{t("nav.enterprise")}
</a> </a>
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<LanguageSwitcher variant="button" /> <LanguageSwitcher variant="select" />
<ThemeToggle /> <ThemeToggle />
<a <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" 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" href="/auth/login"
> >
{t("common.login")} {t("landing.nav.login")}
</a> </a>
<Button <Button asChild className="bg-primary text-primary-foreground font-bold hover:bg-primary/90">
asChild <Link href="/auth/register">{t("landing.nav.startFree")}</Link>
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/auth/register">{t("common.signup")}</Link>
</Button> </Button>
</div> </div>
</div> </div>
</header> </header>
<main> <main>
{/* Hero Section */} {/* ── Hero ── */}
<section className="relative pt-20 pb-32 overflow-hidden"> <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="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="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center">
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-20"> {/* Badge */}
{/* Hero Text */} <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">
<div className="flex-1 text-center lg:text-left space-y-8"> <Sparkles className="h-4 w-4" />
<h1 className="text-4xl font-extrabold tracking-tight text-foreground sm:text-5xl lg:text-6xl xl:text-7xl"> {t("landing.hero.badge")}
{t("hero.title")} </div>
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-emerald-400">
{t("hero.titleHighlight")}
</span>
</h1>
<p className="mx-auto lg:mx-0 max-w-2xl text-lg text-muted-foreground"> <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("hero.subtitle")} {t("landing.hero.title1")}
</p> <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"> <p className="mt-8 mx-auto max-w-2xl text-lg text-muted-foreground">
<Button {t("landing.hero.subtitle")}
asChild </p>
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>
{/* Trust Badges */} <div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<div className="pt-8 flex flex-wrap justify-center lg:justify-start gap-6"> <Button
<div className="flex items-center gap-2 text-sm text-muted-foreground"> asChild
<ShieldCheck className="h-5 w-5 text-primary" /> size="lg"
<span>{t("hero.badge1")}</span> className="bg-primary text-primary-foreground font-bold hover:bg-primary/90 text-base px-8"
</div> >
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <Link href="/auth/register">
<Clock className="h-5 w-5 text-primary" /> {t("landing.hero.cta")}
<span>{t("hero.badge2")}</span> <ArrowRight className="ms-2 h-5 w-5" />
</div> </Link>
</div> </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> </div>
<div className="flex items-center gap-2">
{/* Hero Visual — Word EN | FR + illustrations */} <BadgeCheck className="h-5 w-5 text-primary" />
<div className="flex-1 w-full max-w-lg lg:max-w-none relative"> <span>{t("landing.trust.noBait")}</span>
<HeroWordComparison />
</div> </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>
</div> </div>
</section> </section>
{/* Features Grid */} {/* ── How it works ── */}
<section className="py-24 bg-background relative" id="features"> <section className="py-24 bg-background">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4"> <h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
{t("features.title")} {t("landing.howItWorks.title")}
</h2> </h2>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto"> <p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t("features.subtitle")} {t("landing.howItWorks.subtitle")}
</p> </p>
</div> </div>
<div className="flex flex-wrap justify-center gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{/* 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"> step: "1",
<Table2 className="h-6 w-6" /> 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> </div>
<h3 className="text-xl font-bold text-foreground mb-2"> ))}
{t("features.excel.title")} </div>
</h3> </div>
<p className="text-muted-foreground">{t("features.excel.description")}</p> </section>
</div>
{/* Feature 2 - Word */} {/* ── AI Translation Engine ── */}
<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"> <section className="py-24 bg-muted/30" id="ai-engine">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<FileText className="h-6 w-6" /> <div className="text-center mb-16">
</div> <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">
<h3 className="text-xl font-bold text-foreground mb-2"> <Brain className="h-4 w-4" />
{t("features.word.title")} {t("landing.ai.badge")}
</h3>
<p className="text-muted-foreground">{t("features.word.description")}</p>
</div> </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 */} {/* Three pillars */}
<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="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-5xl mx-auto mb-16">
<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" /> { 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> </div>
<h3 className="text-xl font-bold text-foreground mb-2"> ))}
{t("features.powerpoint.title")} </div>
</h3>
<p className="text-muted-foreground">
{t("features.powerpoint.description")}
</p>
</div>
{/* Feature 4 - AI */} {/* Comparison */}
<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="max-w-3xl mx-auto rounded-2xl border border-border bg-card p-8">
<div className="mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 text-primary"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Bot 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.source")}</p>
<p className="text-sm text-foreground font-medium italic">&ldquo;{t("landing.ai.comparison.sourceText")}&rdquo;</p>
</div> </div>
<h3 className="text-xl font-bold text-foreground mb-2"> <div className="space-y-2">
{t("features.ai.title")} <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{t("landing.ai.comparison.google")}</p>
</h3> <p className="text-sm text-destructive/80">&ldquo;{t("landing.ai.comparison.googleText")}&rdquo;</p>
<p className="text-muted-foreground">{t("features.ai.description")}</p> </div>
</div> <div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-primary">{t("landing.ai.comparison.ai")}</p>
{/* Feature 5 - Speed */} <p className="text-sm text-emerald-600 font-medium">&ldquo;{t("landing.ai.comparison.aiText")}&rdquo;</p>
<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> </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> </div>
</div> </div>
</section> </section>
{/* Pricing Section */} {/* ── Why Us — Format Preservation ── */}
<section className="py-24 bg-muted/30" id="pricing"> <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="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10"> <div className="text-center mb-10">
<h2 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl mb-4"> <h2 className="text-3xl font-bold tracking-tight sm:text-4xl mb-4">
{t("pricing.title")} {t("landing.pricing.title")}
</h2> </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="flex justify-center mt-8">
<div className="inline-flex items-center gap-3 bg-card border border-border rounded-full p-1.5"> <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)} onClick={() => setIsYearly(false)}
className={cn( className={cn(
"px-5 py-2 rounded-full text-sm font-medium transition-all", "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>
<button <button
onClick={() => setIsYearly(true)} onClick={() => setIsYearly(true)}
className={cn( className={cn(
"px-5 py-2 rounded-full text-sm font-medium transition-all flex items-center gap-2", "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")} {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 dark:text-emerald-400")}> <span className={cn(
{t("common.save20")} "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> </span>
</button> </button>
</div> </div>
@@ -250,140 +382,218 @@ export function LandingPage() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-5xl mx-auto"> <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"> <div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
<h3 className="text-xl font-semibold text-foreground"> <h3 className="text-xl font-semibold">{t("landing.pricing.starter.title")}</h3>
{t("pricing.starter.name")} <p className="mt-2 text-muted-foreground text-sm">
</h3> {t("landing.pricing.starter.desc")}
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.starter.description")}
</p> </p>
<div className="mt-6 mb-2"> <div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold">
{isYearly ? t("pricing.starter.priceYearly") : t("pricing.starter.priceMonthly")} {isYearly ? prices.starter.yearly / 12 : prices.starter.monthly}
</span> </span>
<span className="text-muted-foreground">/{t("common.month")}</span> <span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
</div> </div>
<div className="h-6 mb-4"> {isYearly && (
{isYearly && ( <span className="text-sm text-emerald-600 mb-4">{prices.starter.yearly} {t("landing.pricing.billedYearly")}</span>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400"> )}
{t("pricing.starter.billedYearly")} <ul className="space-y-3 mb-8 flex-1 mt-4">
</span> {[
)} t("landing.pricing.starter.f1"),
</div> t("landing.pricing.starter.f2"),
<ul className="space-y-4 mb-8 flex-1"> t("landing.pricing.starter.f3"),
{[0, 1, 2, 3].map((i) => ( t("landing.pricing.starter.f4"),
<li key={i} className="flex items-center text-muted-foreground text-sm"> ].map((f, i) => (
<Check className="h-4 w-4 text-primary mr-2 shrink-0" /> <li key={i} className="flex items-center text-sm text-muted-foreground">
{t(`pricing.starter.features.${i}`)} <Check className="h-4 w-4 text-primary me-2 shrink-0" />{f}
</li> </li>
))} ))}
</ul> </ul>
<Button asChild variant="outline"> <Button asChild variant="outline" className="w-full">
<Link href="/pricing?plan=starter">{t("common.signup")}</Link> <Link href="/pricing?plan=starter">{t("landing.pricing.starter.cta")}</Link>
</Button> </Button>
</div> </div>
{/* Pro Plan */} {/* Pro — Popular */}
<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="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-warning px-3 py-1 text-xs font-bold text-warning-foreground uppercase tracking-wide"> <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("common.popular")} {t("landing.pricing.pro.badge")}
</div> </div>
<h3 className="text-xl font-semibold text-foreground"> <h3 className="text-xl font-semibold">{t("landing.pricing.pro.title")}</h3>
{t("pricing.pro.name")} <p className="mt-2 text-muted-foreground text-sm">
</h3> {t("landing.pricing.pro.desc")}
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.pro.description")}
</p> </p>
<div className="mt-6 mb-2"> <div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold">
{isYearly ? t("pricing.pro.priceYearly") : t("pricing.pro.priceMonthly")} {isYearly ? prices.pro.yearly / 12 : prices.pro.monthly}
</span> </span>
<span className="text-muted-foreground">/{t("common.month")}</span> <span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
</div> </div>
<div className="h-6 mb-4"> {isYearly && (
{isYearly && ( <span className="text-sm text-emerald-600 mb-4">{prices.pro.yearly} {t("landing.pricing.billedYearly")}</span>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400"> )}
{t("pricing.pro.billedYearly")} <ul className="space-y-3 mb-8 flex-1 mt-4">
</span> {[
)} t("landing.pricing.pro.f1"),
</div> t("landing.pricing.pro.f2"),
<ul className="space-y-4 mb-8 flex-1"> t("landing.pricing.pro.f3"),
{[0, 1, 2, 3].map((i) => ( t("landing.pricing.pro.f4"),
<li key={i} className="flex items-center text-foreground text-sm"> t("landing.pricing.pro.f5"),
<Check className="h-4 w-4 text-primary mr-2 shrink-0" /> t("landing.pricing.pro.f6"),
{t(`pricing.pro.features.${i}`)} ].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> </li>
))} ))}
</ul> </ul>
<Button <Button asChild className="w-full bg-primary text-primary-foreground font-bold hover:bg-primary/90">
asChild <Link href="/pricing?plan=pro">{t("landing.pricing.pro.cta")}</Link>
className="bg-primary text-primary-foreground font-bold hover:bg-primary/90"
>
<Link href="/pricing?plan=pro">{t("common.tryPro")}</Link>
</Button> </Button>
</div> </div>
{/* Business Plan */} {/* Business */}
<div className="rounded-2xl border border-border bg-card p-8 flex flex-col"> <div className="rounded-2xl border border-border bg-card p-8 flex flex-col">
<h3 className="text-xl font-semibold text-foreground"> <h3 className="text-xl font-semibold">{t("landing.pricing.business.title")}</h3>
{t("pricing.business.name")} <p className="mt-2 text-muted-foreground text-sm">
</h3> {t("landing.pricing.business.desc")}
<p className="mt-4 text-muted-foreground text-sm">
{t("pricing.business.description")}
</p> </p>
<div className="mt-6 mb-2"> <div className="mt-6 mb-2">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold">
{isYearly ? t("pricing.business.priceYearly") : t("pricing.business.priceMonthly")} {isYearly ? prices.business.yearly / 12 : prices.business.monthly}
</span> </span>
<span className="text-muted-foreground">/{t("common.month")}</span> <span className="text-muted-foreground">{t("landing.pricing.perMonth")}</span>
</div> </div>
<div className="h-6 mb-4"> {isYearly && (
{isYearly && ( <span className="text-sm text-emerald-600 mb-4">{prices.business.yearly} {t("landing.pricing.billedYearly")}</span>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400"> )}
{t("pricing.business.billedYearly")} <ul className="space-y-3 mb-8 flex-1 mt-4">
</span> {[
)} t("landing.pricing.business.f1"),
</div> t("landing.pricing.business.f2"),
<ul className="space-y-4 mb-8 flex-1"> t("landing.pricing.business.f3"),
{[0, 1, 2, 3].map((i) => ( t("landing.pricing.business.f4"),
<li key={i} className="flex items-center text-muted-foreground text-sm"> t("landing.pricing.business.f5"),
<Check className="h-4 w-4 text-primary mr-2 shrink-0" /> t("landing.pricing.business.f6"),
{t(`pricing.business.features.${i}`)} ].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> </li>
))} ))}
</ul> </ul>
<Button asChild variant="outline"> <Button asChild variant="outline" className="w-full">
<Link href="/pricing?plan=business">{t("common.tryPro")}</Link> <Link href="/pricing?plan=business">{t("landing.pricing.business.cta")}</Link>
</Button> </Button>
</div> </div>
</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> </div>
</section> </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"> <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="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="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" /> <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"> <h2 className="text-3xl font-bold mb-6">
{t("cta.title")} {t("landing.cta.title")}
</h2> </h2>
<p className="text-muted-foreground mb-8 max-w-2xl mx-auto"> <p className="text-muted-foreground mb-8 max-w-2xl mx-auto">
{t("cta.subtitle")} {t("landing.cta.subtitle")}
</p> </p>
<Button <Button
asChild asChild
size="lg" 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> </Button>
</div> </div>
</section> </section>
</main> </main>
{/* Footer */} {/* ── Footer ── */}
<footer className="border-t border-border bg-background py-12"> <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="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"> <div className="flex flex-col md:flex-row justify-between items-center gap-6">
@@ -396,17 +606,13 @@ export function LandingPage() {
</span> </span>
</div> </div>
<div className="flex gap-6 text-sm text-muted-foreground"> <div className="flex gap-6 text-sm text-muted-foreground">
<a className="hover:text-foreground transition-colors" href="#"> <a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.privacy")}</a>
{t("footer.privacy")} <a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.terms")}</a>
</a> <a className="hover:text-foreground transition-colors" href="#">{t("landing.footer.contact")}</a>
<a className="hover:text-foreground transition-colors" href="#"> </div>
{t("footer.terms")} <div className="text-muted-foreground text-sm">
</a> © {new Date().getFullYear()} Office Translator
<a className="hover:text-foreground transition-colors" href="#">
{t("footer.contact")}
</a>
</div> </div>
<div className="text-muted-foreground text-sm">{t("footer.copyright")}</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -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>
);
}

View File

@@ -117,7 +117,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
{/* Icon */} {/* Icon */}
{icon && ( {icon && (
<span className="mr-1 flex-shrink-0"> <span className="me-1 flex-shrink-0">
{icon} {icon}
</span> </span>
)} )}
@@ -126,7 +126,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{children} {children}
{displayCount && ( {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} {displayCount}
</span> </span>
)} )}
@@ -137,7 +137,7 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
<button <button
type="button" type="button"
onClick={handleRemove} 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" aria-label="Remove badge"
> >
<svg <svg

View File

@@ -118,7 +118,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{/* Loading State */} {/* Loading State */}
{loading && ( {loading && (
<svg <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" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -84,7 +84,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" 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} {...props}
/> />
) )

View File

@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -92,7 +92,7 @@ function DropdownMenuCheckboxItem({
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( 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 className
)} )}
checked={checked} checked={checked}
@@ -128,7 +128,7 @@ function DropdownMenuRadioItem({
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( 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 className
)} )}
{...props} {...props}
@@ -155,7 +155,7 @@ function DropdownMenuLabel({
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -211,7 +211,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -80,8 +80,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
"outline-none focus:outline-none", "outline-none focus:outline-none",
"read-only:cursor-default read-only:bg-surface/50", "read-only:cursor-default read-only:bg-surface/50",
variants[variant], variants[variant],
leftIcon && "pl-10", leftIcon && "ps-10",
rightIcon && "pr-10", rightIcon && "pe-10",
(leftIcon && rightIcon) && "px-10", (leftIcon && rightIcon) && "px-10",
error && "text-destructive", error && "text-destructive",
className className
@@ -264,7 +264,7 @@ export const SearchInput = React.forwardRef<
) )
} }
className={cn( className={cn(
"pl-10 pr-10", "ps-10 pe-10",
focused && "shadow-lg shadow-primary/10", focused && "shadow-lg shadow-primary/10",
className className
)} )}
@@ -331,7 +331,7 @@ export const FileInput = React.forwardRef<
accept={accept} accept={accept}
multiple={multiple} multiple={multiple}
className={cn( 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", dragActive && "border-primary bg-primary/5",
className className
)} )}
@@ -344,7 +344,7 @@ export const FileInput = React.forwardRef<
/> />
{fileName && ( {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"> <span className="text-xs text-muted-foreground truncate max-w-32">
{fileName} {fileName}
</span> </span>

View File

@@ -14,6 +14,17 @@ import { useI18n, type Locale } from "@/lib/i18n";
const languages: { value: Locale; label: string; flag: string }[] = [ const languages: { value: Locale; label: string; flag: string }[] = [
{ value: "en", label: "English", flag: "🇬🇧" }, { value: "en", label: "English", flag: "🇬🇧" },
{ value: "fr", label: "Français", 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 { interface LanguageSwitcherProps {
@@ -43,7 +54,7 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
return ( return (
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}> <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> <SelectValue>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Globe className="h-4 w-4" /> <Globe className="h-4 w-4" />
@@ -52,12 +63,11 @@ export function LanguageSwitcher({ variant = "select" }: LanguageSwitcherProps)
</span> </span>
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-surface-dark border-border-dark"> <SelectContent>
{languages.map((lang) => ( {languages.map((lang) => (
<SelectItem <SelectItem
key={lang.value} key={lang.value}
value={lang.value} value={lang.value}
className="hover:bg-primary/10 focus:bg-primary/10"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span>{lang.flag}</span> <span>{lang.flag}</span>

View File

@@ -153,7 +153,7 @@ export function ModelCombobox({
<span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span> <span className="text-muted-foreground">ctx: {(model.context_length / 1000).toFixed(0)}k</span>
)} )}
</div> </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> </li>
)) ))
)} )}

View File

@@ -6,7 +6,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucid
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const notificationVariants = cva( 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: { variants: {
variant: { variant: {

View File

@@ -109,7 +109,7 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -74,7 +74,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -89,7 +89,7 @@ const TableCell = React.forwardRef<
<td <td
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -7,7 +7,7 @@ import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const toastVariants = cva( 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: { variants: {
variant: { variant: {

View File

@@ -21,9 +21,9 @@ logger = logging.getLogger(__name__)
class RateLimitConfig: class RateLimitConfig:
"""Configuration for rate limiting""" """Configuration for rate limiting"""
# Requests per window # Requests per window
requests_per_minute: int = 30 requests_per_minute: int = 120
requests_per_hour: int = 200 requests_per_hour: int = 1000
requests_per_day: int = 1000 requests_per_day: int = 5000
# Translation-specific limits # Translation-specific limits
translations_per_minute: int = 10 translations_per_minute: int = 10
@@ -35,7 +35,7 @@ class RateLimitConfig:
max_total_size_per_hour_mb: int = 500 max_total_size_per_hour_mb: int = 500
# Burst protection # 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 (no rate limiting)
whitelist_ips: list = field(default_factory=lambda: ["127.0.0.1", "::1"]) 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]: async def check_request(self, request: Request) -> tuple[bool, str, str]:
"""Check if request is allowed, return (allowed, reason, client_id)""" """Check if request is allowed, return (allowed, reason, client_id)"""
client_id = self.get_client_id(request) 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 self._total_requests += 1
if self.is_whitelisted(client_id): if self.is_whitelisted(client_id):
@@ -387,6 +404,19 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
if request.url.path.startswith("/static"): if request.url.path.startswith("/static"):
return await call_next(request) 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 # Check rate limit
allowed, reason, client_id = await self.manager.check_request(request) allowed, reason, client_id = await self.manager.check_request(request)

View File

@@ -1,12 +1,10 @@
""" """
Tier-based daily translation quota (Story 1.6). Tier-based monthly translation quota.
Uses Redis sliding-window daily counter per user; fallback in-memory when Redis unavailable. Uses Redis counter per user per month; fallback in-memory when Redis unavailable.
Coexists with IP-based rate limiting in rate_limiting.py. 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. Free tier: 2 translations per calendar month.
User.daily_translation_count in DB is kept in sync on each successful translation for Paid tiers (starter/pro/business/enterprise): unlimited.
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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,65 +16,65 @@ from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Free tier: 5 translations per day (UTC). Pro (and equivalent) tiers: no daily cap. # Free tier: 2 translations per calendar month.
FREE_TIER_DAILY_LIMIT = 5 FREE_TIER_MONTHLY_LIMIT = 2
KEY_PREFIX = "rate_limit:daily" KEY_PREFIX = "quota:monthly"
def _utc_date_str(dt: Optional[datetime] = None) -> str: def _utc_month_str(dt: Optional[datetime] = None) -> str:
"""Current date in UTC as YYYY-MM-DD.""" """Current month in UTC as YYYY-MM."""
t = dt or datetime.now(timezone.utc) 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: def _next_month_utc(dt: Optional[datetime] = None) -> datetime:
"""Next midnight UTC after the given time (or now).""" """First second of next month UTC."""
now = dt or datetime.now(timezone.utc) now = dt or datetime.now(timezone.utc)
tomorrow = (now.date() + timedelta(days=1)) if now.month == 12:
return datetime(tomorrow.year, tomorrow.month, tomorrow.day, tzinfo=timezone.utc) 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: def _seconds_until_next_month(dt: Optional[datetime] = None) -> int:
"""Seconds until next midnight UTC.""" """Seconds until start of next month UTC."""
now = dt or datetime.now(timezone.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 @dataclass
class QuotaResult: class QuotaResult:
"""Result of a quota check.""" """Result of a quota check."""
allowed: bool allowed: bool
remaining: int # -1 for pro (unlimited) remaining: int # -1 for paid tiers (unlimited)
reset_at_utc: datetime reset_at_utc: datetime
current_usage: int = 0 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(): def _get_async_redis():
"""Return async Redis client or None. Uses shared client from core.redis.""" """Return async Redis client or None. Uses shared client from core.redis."""
from core.redis import get_async_redis from core.redis import get_async_redis
return 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: def _memory_get(user_id: str, month_str: str) -> int:
return _memory_usage.get((user_id, date_str), 0) return _memory_usage.get((user_id, month_str), 0)
def _memory_incr(user_id: str, date_str: str) -> int: def _memory_incr(user_id: str, month_str: str) -> int:
key = (user_id, date_str) key = (user_id, month_str)
_memory_usage[key] = _memory_usage.get(key, 0) + 1 _memory_usage[key] = _memory_usage.get(key, 0) + 1
return _memory_usage[key] return _memory_usage[key]
@@ -87,28 +85,21 @@ def _memory_incr(user_id: str, date_str: str) -> int:
class TierQuotaService: class TierQuotaService:
""" """
Daily translation quota per user by tier. Monthly translation quota per user by tier.
Redis key pattern: rate_limit:daily:{user_id}:{YYYY-MM-DD}, TTL 25h. Redis key pattern: quota:monthly:{user_id}:{YYYY-MM}, TTL 32 days.
If Redis is unavailable, uses in-memory dict (documented fallback).
""" """
def __init__(self): def __init__(self):
self._redis = None # Lazy init on first use self._redis = None
def _redis_client(self): def _redis_client(self):
if self._redis is None: if self._redis is None:
self._redis = _get_async_redis() self._redis = _get_async_redis()
return self._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: async def check_quota(self, user_id: str, tier: str) -> QuotaResult:
""" """Check monthly quota. Free = 2/month; paid = unlimited."""
Check if user has quota for one more translation today (UTC). reset_at = _next_month_utc()
tier "free" -> limit 5/day; "pro" (or equivalent) -> unlimited.
"""
reset_at = _next_midnight_utc()
tier_lower = (tier or "free").lower() tier_lower = (tier or "free").lower()
if tier_lower in ("pro", "business", "enterprise", "starter"): if tier_lower in ("pro", "business", "enterprise", "starter"):
return QuotaResult( return QuotaResult(
@@ -118,48 +109,48 @@ class TierQuotaService:
current_usage=0, current_usage=0,
limit=0, limit=0,
) )
# Free tier # Free tier — monthly counter
date_str = self._date_str() month_str = _utc_month_str()
redis_client = self._redis_client() redis_client = self._redis_client()
if redis_client: if redis_client:
try: try:
key = f"{KEY_PREFIX}:{user_id}:{date_str}" key = f"{KEY_PREFIX}:{user_id}:{month_str}"
count = await redis_client.get(key) count = await redis_client.get(key)
count = int(count or 0) count = int(count or 0)
except Exception as e: except Exception as e:
logger.warning("Tier quota Redis get failed: %s, using in-memory", 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: else:
count = _memory_get(user_id, date_str) count = _memory_get(user_id, month_str)
remaining = max(0, FREE_TIER_DAILY_LIMIT - count) remaining = max(0, FREE_TIER_MONTHLY_LIMIT - count)
return QuotaResult( return QuotaResult(
allowed=count < FREE_TIER_DAILY_LIMIT, allowed=count < FREE_TIER_MONTHLY_LIMIT,
remaining=remaining, remaining=remaining,
reset_at_utc=reset_at, reset_at_utc=reset_at,
current_usage=count, current_usage=count,
limit=FREE_TIER_DAILY_LIMIT, limit=FREE_TIER_MONTHLY_LIMIT,
) )
async def increment_on_success(self, user_id: str) -> None: async def increment_on_success(self, user_id: str) -> None:
"""Increment daily translation count for user (call after successful translation).""" """Increment monthly translation count (call after successful translation)."""
date_str = self._date_str() month_str = _utc_month_str()
redis_client = self._redis_client() redis_client = self._redis_client()
if redis_client: if redis_client:
try: try:
key = f"{KEY_PREFIX}:{user_id}:{date_str}" key = f"{KEY_PREFIX}:{user_id}:{month_str}"
pipe = redis_client.pipeline() pipe = redis_client.pipeline()
pipe.incr(key) 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() await pipe.execute()
return return
except Exception as e: except Exception as e:
logger.warning("Tier quota Redis increment failed: %s, using in-memory", 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: def seconds_until_reset(self) -> int:
"""Seconds until next midnight UTC (for Retry-After header).""" """Seconds until start of next month UTC."""
return _seconds_until_midnight_utc() return _seconds_until_next_month()
# Singleton for app use # Singleton
tier_quota_service = TierQuotaService() tier_quota_service = TierQuotaService()

View File

@@ -57,10 +57,12 @@ class FileValidator:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/pdf": ".pdf",
} }
# Magic bytes for Office Open XML files (ZIP format) # Magic bytes for Office Open XML files (ZIP format)
OFFICE_MAGIC_BYTES = b"PK\x03\x04" OFFICE_MAGIC_BYTES = b"PK\x03\x04"
PDF_MAGIC_BYTES = b"%PDF"
def __init__( def __init__(
self, self,
@@ -70,7 +72,7 @@ class FileValidator:
): ):
self.max_size_bytes = max_size_mb * 1024 * 1024 self.max_size_bytes = max_size_mb * 1024 * 1024
self.max_size_mb = max_size_mb 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 self.scan_content = scan_content
async def validate_async(self, file: UploadFile) -> ValidationResult: async def validate_async(self, file: UploadFile) -> ValidationResult:
@@ -281,11 +283,20 @@ class FileValidator:
def _validate_magic_bytes(self, content: bytes, extension: str): def _validate_magic_bytes(self, content: bytes, extension: str):
"""Validate file magic bytes match expected format""" """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): if not content.startswith(self.OFFICE_MAGIC_BYTES):
raise ValidationError( raise ValidationError(
"Le contenu du fichier ne correspond pas au format attendu. " "File content does not match expected Office format. "
"Le fichier est peut-etre corrompu ou n'est pas un document Office valide.", "The file may be corrupted or not a valid Office document.",
code="invalid_file_content", code="invalid_file_content",
) )

View File

@@ -37,28 +37,29 @@ import os
# or google/gemini-3-flash ($0.15 / $0.60) # or google/gemini-3-flash ($0.15 / $0.60)
PLANS = { PLANS = {
PlanType.FREE: { PlanType.FREE: {
"name": "Gratuit", "name": "Free",
"price_monthly": 0, "price_monthly": 0,
"price_yearly": 0, "price_yearly": 0,
"docs_per_month": 5, "docs_per_month": 2,
"max_pages_per_doc": 15, "max_pages_per_doc": 10,
"max_file_size_mb": 5, "max_file_size_mb": 5,
"max_chars_per_month": 50_000, "max_chars_per_month": 20_000,
"providers": ["google"], "providers": ["google"],
"features": [ "features": [
"5 documents / mois", "pricing.plans.free.feat1",
"Jusqu'à 15 pages par document", "pricing.plans.free.feat2",
"Google Traduction inclus", "pricing.plans.free.feat3",
"Toutes les langues (130+)", "pricing.plans.free.feat4",
"Support communautaire", "pricing.plans.free.feat5",
], ],
"ai_translation": False, "ai_translation": False,
"api_access": False, "api_access": False,
"priority_processing": False, "priority_processing": False,
"watermark": True,
"stripe_price_id_monthly": None, "stripe_price_id_monthly": None,
"stripe_price_id_yearly": None, "stripe_price_id_yearly": None,
"highlight": None, "highlight": None,
"description": "Parfait pour découvrir l'application", "description": "pricing.plans.free.description",
"badge": None, "badge": None,
}, },
PlanType.STARTER: { PlanType.STARTER: {
@@ -71,12 +72,12 @@ PLANS = {
"max_chars_per_month": 500_000, "max_chars_per_month": 500_000,
"providers": ["google", "deepl"], "providers": ["google", "deepl"],
"features": [ "features": [
"50 documents / mois", "pricing.plans.starter.feat1",
"Jusqu'à 50 pages par document", "pricing.plans.starter.feat2",
"Google Traduction + DeepL", "pricing.plans.starter.feat3",
"Fichiers jusqu'à 10 Mo", "pricing.plans.starter.feat4",
"Support par e-mail", "pricing.plans.starter.feat5",
"Historique 30 jours", "pricing.plans.starter.feat6",
], ],
"ai_translation": False, "ai_translation": False,
"api_access": False, "api_access": False,
@@ -84,7 +85,7 @@ PLANS = {
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_STARTER_MONTHLY", ""), "stripe_price_id_monthly": os.getenv("STRIPE_PRICE_STARTER_MONTHLY", ""),
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_STARTER_YEARLY", ""), "stripe_price_id_yearly": os.getenv("STRIPE_PRICE_STARTER_YEARLY", ""),
"highlight": None, "highlight": None,
"description": "Pour les particuliers et petits projets", "description": "pricing.plans.starter.description",
"badge": None, "badge": None,
}, },
PlanType.PRO: { PlanType.PRO: {
@@ -98,14 +99,14 @@ PLANS = {
"providers": ["google", "google_cloud", "deepl", "openrouter"], "providers": ["google", "google_cloud", "deepl", "openrouter"],
"ai_model_essential": "deepseek/deepseek-v3.2", "ai_model_essential": "deepseek/deepseek-v3.2",
"features": [ "features": [
"200 documents / mois", "pricing.plans.pro.feat1",
"Jusqu'à 200 pages par document", "pricing.plans.pro.feat2",
"Traduction IA Essentielle incluse (DeepSeek V3.2)", "pricing.plans.pro.feat3",
"Google Traduction + DeepL", "pricing.plans.pro.feat4",
"Fichiers jusqu'à 25 Mo", "pricing.plans.pro.feat5",
"Glossaires personnalisés", "pricing.plans.pro.feat6",
"Support prioritaire par e-mail", "pricing.plans.pro.feat7",
"Historique 90 jours", "pricing.plans.pro.feat8",
], ],
"ai_translation": True, "ai_translation": True,
"ai_tier": "essential", "ai_tier": "essential",
@@ -113,9 +114,9 @@ PLANS = {
"priority_processing": True, "priority_processing": True,
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_PRO_MONTHLY", ""), "stripe_price_id_monthly": os.getenv("STRIPE_PRICE_PRO_MONTHLY", ""),
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_PRO_YEARLY", ""), "stripe_price_id_yearly": os.getenv("STRIPE_PRICE_PRO_YEARLY", ""),
"highlight": "Le plus populaire", "highlight": "pricing.plans.pro.highlight",
"description": "Pour les professionnels et équipes en croissance", "description": "pricing.plans.pro.description",
"badge": "POPULAIRE", "badge": "POPULAR",
}, },
PlanType.BUSINESS: { PlanType.BUSINESS: {
"name": "Business", "name": "Business",
@@ -129,17 +130,16 @@ PLANS = {
"ai_model_essential": "deepseek/deepseek-v3.2", "ai_model_essential": "deepseek/deepseek-v3.2",
"ai_model_premium": "anthropic/claude-3.5-haiku", "ai_model_premium": "anthropic/claude-3.5-haiku",
"features": [ "features": [
"1 000 documents / mois", "pricing.plans.business.feat1",
"Jusqu'à 500 pages par document", "pricing.plans.business.feat2",
"Traduction IA Essentielle + Premium (Claude Haiku)", "pricing.plans.business.feat3",
"Tous les fournisseurs de traduction", "pricing.plans.business.feat4",
"Fichiers jusqu'à 50 Mo", "pricing.plans.business.feat5",
"Accès API (10 000 appels/mois)", "pricing.plans.business.feat6",
"Webhooks de notification", "pricing.plans.business.feat7",
"Glossaires + Prompts personnalisés", "pricing.plans.business.feat8",
"Support dédié", "pricing.plans.business.feat9",
"Historique 1 an", "pricing.plans.business.feat10",
"Analytiques avancées",
], ],
"ai_translation": True, "ai_translation": True,
"ai_tier": "premium", "ai_tier": "premium",
@@ -150,11 +150,11 @@ PLANS = {
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_BUSINESS_MONTHLY", ""), "stripe_price_id_monthly": os.getenv("STRIPE_PRICE_BUSINESS_MONTHLY", ""),
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_BUSINESS_YEARLY", ""), "stripe_price_id_yearly": os.getenv("STRIPE_PRICE_BUSINESS_YEARLY", ""),
"highlight": None, "highlight": None,
"description": "Pour les équipes et organisations", "description": "pricing.plans.business.description",
"badge": None, "badge": None,
}, },
PlanType.ENTERPRISE: { PlanType.ENTERPRISE: {
"name": "Entreprise", "name": "Enterprise",
"price_monthly": -1, "price_monthly": -1,
"price_yearly": -1, "price_yearly": -1,
"docs_per_month": -1, "docs_per_month": -1,
@@ -163,15 +163,14 @@ PLANS = {
"max_chars_per_month": -1, "max_chars_per_month": -1,
"providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai", "custom"], "providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai", "custom"],
"features": [ "features": [
"Documents illimités", "pricing.plans.enterprise.feat1",
"Tous les modèles IA (GPT-5, Claude Opus 4.6...)", "pricing.plans.enterprise.feat2",
"Déploiement on-premise ou cloud dédié", "pricing.plans.enterprise.feat3",
"SLA 99,9 % garanti", "pricing.plans.enterprise.feat4",
"Support 24/7 dédié", "pricing.plans.enterprise.feat5",
"Modèles IA personnalisés", "pricing.plans.enterprise.feat6",
"Marque blanche (white-label)", "pricing.plans.enterprise.feat7",
"Équipes illimitées", "pricing.plans.enterprise.feat8",
"Intégrations sur mesure",
], ],
"ai_translation": True, "ai_translation": True,
"ai_tier": "custom", "ai_tier": "custom",
@@ -182,8 +181,8 @@ PLANS = {
"stripe_price_id_monthly": None, "stripe_price_id_monthly": None,
"stripe_price_id_yearly": None, "stripe_price_id_yearly": None,
"highlight": None, "highlight": None,
"description": "Solutions sur mesure pour grandes organisations", "description": "pricing.plans.enterprise.description",
"badge": "SUR DEVIS", "badge": "ON REQUEST",
}, },
} }
@@ -243,13 +242,13 @@ class UserCreate(BaseModel):
def validate_password_strength(cls, v: str) -> str: def validate_password_strength(cls, v: str) -> str:
"""Validate password meets minimum security requirements.""" """Validate password meets minimum security requirements."""
if len(v) < 8: 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): 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): 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): 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 return v

View File

@@ -4,6 +4,10 @@ python-multipart==0.0.9
openpyxl==3.1.2 openpyxl==3.1.2
python-docx==1.1.0 python-docx==1.1.0
python-pptx==0.6.23 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 deep-translator==1.11.4
python-dotenv==1.0.0 python-dotenv==1.0.0
pydantic==2.5.3 pydantic==2.5.3

View File

@@ -592,6 +592,34 @@ async def get_tracked_files(is_admin: bool = Depends(require_admin)):
return {"count": len(tracked), "files": tracked} 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") @router.post("/config/provider")
async def update_default_provider( async def update_default_provider(
provider: str = Form(...), provider: str = Form(...),

View File

@@ -11,17 +11,15 @@ from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, Request, HTTPException from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field 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.connection import get_sync_session
from database.models import ApiKey from database.models import ApiKey
router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"]) router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"])
security = HTTPBearer(auto_error=False)
MAX_API_KEYS_PER_USER = 10 MAX_API_KEYS_PER_USER = 10
@@ -38,95 +36,6 @@ class ApiKeyResponse(BaseModel):
created_at: str 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]: def _generate_api_key() -> tuple[str, str, str]:
""" """
Generate a secure API key with sk_live_ prefix. 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( async def create_api_key(
request: Request, request: Request,
body: Optional[ApiKeyCreateRequest] = None, 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. Create a new API key for the authenticated Pro user.
@@ -213,7 +122,7 @@ async def create_api_key(
@router.get("") @router.get("")
async def list_api_keys( async def list_api_keys(
request: Request, request: Request,
user: ProUser = Depends(_require_pro_user), user: ProUser = Depends(require_pro_user),
): ):
""" """
List all API keys for the authenticated Pro user. List all API keys for the authenticated Pro user.
@@ -260,7 +169,7 @@ async def list_api_keys(
@router.delete("/{key_id}") @router.delete("/{key_id}")
async def revoke_api_key( async def revoke_api_key(
key_id: str, key_id: str,
user: ProUser = Depends(_require_pro_user), user: ProUser = Depends(require_pro_user),
): ):
""" """
Revoke an API key for the authenticated Pro user. Revoke an API key for the authenticated Pro user.

View File

@@ -161,7 +161,7 @@ Créer un nouveau compte utilisateur.
- Le `tier` par défaut est "free" - Le `tier` par défaut est "free"
**Codes d'erreur:** **Codes d'erreur:**
- `INVALID_EMAIL`: Format d'email invalide - `INVALID_EMAIL`: Invalid email format
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email - `EMAIL_EXISTS`: Un compte existe déjà avec cet email
- `INVALID_REQUEST`: Données d'inscription invalides - `INVALID_REQUEST`: Données d'inscription invalides
""", """,
@@ -187,17 +187,17 @@ Créer un nouveau compte utilisateur.
"application/json": { "application/json": {
"examples": { "examples": {
"INVALID_EMAIL": { "INVALID_EMAIL": {
"summary": "Format d'email invalide", "summary": "Invalid email format",
"value": { "value": {
"error": "INVALID_EMAIL", "error": "INVALID_EMAIL",
"message": "Format d'email invalide", "message": "Invalid email format",
}, },
}, },
"EMAIL_EXISTS": { "EMAIL_EXISTS": {
"summary": "Email déjà utilisé", "summary": "Email already used",
"value": { "value": {
"error": "EMAIL_EXISTS", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_EMAIL", "error": "INVALID_EMAIL",
"message": "Format d'email invalide", "message": "Invalid email format",
}, },
) )
# Check if it's a password validation error # Check if it's a password validation error
@@ -241,7 +241,7 @@ async def register_v1(request: Request):
# Otherwise it's a weak password # Otherwise it's a weak password
# Translate common pydantic messages to French # Translate common pydantic messages to French
if "at least 8 characters" in msg.lower() or "8 caractères" in msg: 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( return JSONResponse(
status_code=400, status_code=400,
content={ content={
@@ -253,7 +253,7 @@ async def register_v1(request: Request):
status_code=400, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=503,
content={ content={
"error": "AUTH_HASHING_UNAVAILABLE", "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, status_code=400,
content={ content={
"error": "EMAIL_EXISTS", "error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email", "message": "An account already exists with this email address",
}, },
) )
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={ content={
"error": "REGISTRATION_FAILED", "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 - HTTP 200 avec message de confirmation
**Codes d'erreur:** **Codes d'erreur:**
- `TOKEN_MISSING`: Token d'authentification manquant - `TOKEN_MISSING`: Authentication token missing
- `TOKEN_INVALID`: Token invalide ou expiré - `TOKEN_INVALID`: Token invalide ou expiré
""", """,
responses={ responses={
@@ -326,7 +326,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
"description": "Déconnexion réussie", "description": "Déconnexion réussie",
"content": { "content": {
"application/json": { "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": { "application/json": {
"examples": { "examples": {
"TOKEN_MISSING": { "TOKEN_MISSING": {
"summary": "Token manquant", "summary": "Missing token",
"value": { "value": {
"error": "TOKEN_MISSING", "error": "TOKEN_MISSING",
"message": "Token d'authentification requis", "message": "Authentication token required",
}, },
}, },
"TOKEN_INVALID": { "TOKEN_INVALID": {
"summary": "Token invalide", "summary": "Invalid token",
"value": { "value": {
"error": "TOKEN_INVALID", "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, status_code=401,
content={ content={
"error": "TOKEN_MISSING", "error": "TOKEN_MISSING",
"message": "Token d'authentification requis", "message": "Authentication token required",
}, },
) )
access_token = auth_header[7:] access_token = auth_header[7:]
@@ -374,7 +374,7 @@ async def logout_v1(request: Request):
status_code=401, status_code=401,
content={ content={
"error": "TOKEN_INVALID", "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( return JSONResponse(
status_code=200, 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:** **Codes d'erreur:**
- `INVALID_REQUEST`: Corps de requête JSON invalide - `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 - `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
""", """,
responses={ responses={
@@ -441,17 +441,17 @@ Authentifie un utilisateur et retourne les tokens JWT.
"application/json": { "application/json": {
"examples": { "examples": {
"INVALID_REQUEST": { "INVALID_REQUEST": {
"summary": "Corps invalide", "summary": "Invalid body",
"value": { "value": {
"error": "INVALID_REQUEST", "error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide", "message": "Invalid JSON request body",
}, },
}, },
"INVALID_EMAIL": { "INVALID_EMAIL": {
"summary": "Email invalide", "summary": "Invalid email",
"value": { "value": {
"error": "INVALID_EMAIL", "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": { "application/json": {
"example": { "example": {
"error": "INVALID_CREDENTIALS", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_EMAIL", "error": "INVALID_EMAIL",
"message": "Format d'email invalide", "message": "Invalid email format",
}, },
) )
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=401,
content={ content={
"error": "INVALID_CREDENTIALS", "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, status_code=401,
content={ content={
"error": "INVALID_CREDENTIALS", "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: if not body.credential and not body.access_token:
return JSONResponse( return JSONResponse(
status_code=400, 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", "") google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
@@ -574,13 +574,13 @@ async def google_auth_v1(body: GoogleAuthRequest):
except Exception: except Exception:
return JSONResponse( return JSONResponse(
status_code=503, 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: if resp.status_code != 200:
return JSONResponse( return JSONResponse(
status_code=401, 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() 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: if body.credential and google_client_id and google_data.get("aud") != google_client_id:
return JSONResponse( return JSONResponse(
status_code=401, 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") email = google_data.get("email")
if not email: if not email:
return JSONResponse( return JSONResponse(
status_code=400, 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] 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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "error": "INVALID_REQUEST",
"message": "Refresh token requis", "message": "Refresh token required",
}, },
) )
@@ -669,7 +669,7 @@ async def refresh_v1(request: Request):
status_code=401, status_code=401,
content={ content={
"error": "TOKEN_EXPIRED", "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, status_code=401,
content={ content={
"error": "TOKEN_EXPIRED", "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, status_code=401,
content={ content={
"error": "TOKEN_EXPIRED", "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, status_code=400,
content={ content={
"error": "BILLING_UNAVAILABLE", "error": "BILLING_UNAVAILABLE",
"message": "Portail de facturation non disponible", "message": "Billing portal unavailable",
}, },
) )
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}}) return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
@@ -928,7 +928,7 @@ async def forgot_password(request: Request):
status_code=503, status_code=503,
content={ content={
"error": "EMAIL_SERVICE_UNAVAILABLE", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "error": "INVALID_REQUEST",
"message": "Email requis", "message": "Email required",
}, },
) )
@@ -1015,7 +1015,7 @@ async def forgot_password(request: Request):
status_code=200, status_code=200,
content={ content={
"data": { "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": {}, "meta": {},
}, },
@@ -1041,7 +1041,7 @@ async def reset_password(request: Request):
status_code=400, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "INVALID_REQUEST", "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, status_code=400,
content={ content={
"error": "WEAK_PASSWORD", "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, status_code=400,
content={ content={
"error": "INVALID_TOKEN", "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, status_code=400,
content={ content={
"error": "TOKEN_EXPIRED", "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, status_code=500,
content={ content={
"error": "RESET_FAILED", "error": "RESET_FAILED",
"message": "Erreur lors de la reinitialisation", "message": "Error during password reset",
}, },
) )
return JSONResponse( return JSONResponse(
status_code=200, status_code=200,
content={ content={
"data": {"message": "Mot de passe reinitialise avec succes"}, "data": {"message": "Password reset successfully"},
"meta": {}, "meta": {},
}, },
) )

Some files were not shown because too many files have changed in this diff Show More