Merge branch 'update_blog' into dev
All checks were successful
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Unit Tests (push) Successful in 10m40s
Dev CI / Notify Failure (push) Has been skipped
Dev CI / Backend — Lint (push) Successful in 10m29s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m5s
All checks were successful
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Unit Tests (push) Successful in 10m40s
Dev CI / Notify Failure (push) Has been skipped
Dev CI / Backend — Lint (push) Successful in 10m29s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m5s
This commit is contained in:
commit
3a2a14b5b7
393
INDEX.md
393
INDEX.md
@ -1,348 +1,81 @@
|
||||
# 📑 Xpeditis Documentation Index
|
||||
|
||||
Complete guide to all documentation files in the Xpeditis project.
|
||||
# Index de documentation — Xpeditis
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started (Read First)
|
||||
## Démarrage
|
||||
|
||||
Start here if you're new to the project:
|
||||
|
||||
1. **[README.md](README.md)** - Project overview and quick start
|
||||
2. **[QUICK-START.md](QUICK-START.md)** ⚡ - Get running in 5 minutes
|
||||
3. **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** - Detailed installation guide
|
||||
4. **[NEXT-STEPS.md](NEXT-STEPS.md)** - What to do after setup
|
||||
| Fichier | Description |
|
||||
|---------|-------------|
|
||||
| [README.md](README.md) | Vue d'ensemble du projet |
|
||||
| [QUICK-START.md](QUICK-START.md) | Démarrage en 5 minutes |
|
||||
| [CLAUDE.md](CLAUDE.md) | Architecture hexagonale, conventions, règles |
|
||||
| [docs/README.md](docs/README.md) | Index complet de la documentation |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Project Status & Planning
|
||||
## Documentation complète
|
||||
|
||||
### Sprint 0 (Complete ✅)
|
||||
Toute la documentation est organisée dans [docs/](docs/) :
|
||||
|
||||
- **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** - Complete Sprint 0 report
|
||||
- All deliverables
|
||||
- Architecture details
|
||||
- How to use
|
||||
- Success criteria
|
||||
|
||||
- **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** - Executive summary
|
||||
- Objectives achieved
|
||||
- Metrics
|
||||
- Key features
|
||||
- Next steps
|
||||
|
||||
- **[SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md)** - Technical completion checklist
|
||||
- Week-by-week breakdown
|
||||
- Files created
|
||||
- Remaining tasks
|
||||
|
||||
### Project Roadmap
|
||||
|
||||
- **[TODO.md](TODO.md)** 📅 - 30-week MVP development roadmap
|
||||
- Sprint-by-sprint breakdown
|
||||
- Detailed tasks with checkboxes
|
||||
- Phase 1-4 planning
|
||||
- Go-to-market strategy
|
||||
|
||||
- **[PRD.md](PRD.md)** 📋 - Product Requirements Document
|
||||
- Business context
|
||||
- Functional specifications
|
||||
- Technical requirements
|
||||
- Success metrics
|
||||
```
|
||||
docs/
|
||||
├── README.md # Index principal
|
||||
├── getting-started/ # Installation et démarrage
|
||||
│ ├── quick-start.md # Guide rapide mis à jour
|
||||
│ ├── installation.md # Installation détaillée
|
||||
│ └── windows.md # Spécifique Windows
|
||||
│
|
||||
├── architecture/ # Documentation technique
|
||||
│ ├── overview.md # Vue d'ensemble système
|
||||
│ ├── database.md # Schéma BDD complet (21 tables)
|
||||
│ ├── backend.md # NestJS hexagonal, patterns
|
||||
│ └── frontend.md # Next.js 14, App Router, i18n
|
||||
│
|
||||
├── features/ # Documentation par fonctionnalité
|
||||
│ ├── auth.md # Auth JWT/OAuth/API Keys + RBAC
|
||||
│ ├── bookings.md # Réservations standard
|
||||
│ ├── csv-bookings.md # CSV bookings + portail carrier
|
||||
│ ├── rate-search.md # Recherche tarifs FCL + CSV
|
||||
│ ├── subscriptions.md # Stripe + abonnements
|
||||
│ ├── notifications.md # WebSocket + webhooks
|
||||
│ └── api-access.md # Clés API
|
||||
│
|
||||
├── deployment/ # Déploiement
|
||||
│ ├── portainer.md # Portainer / Docker Swarm (consolidé)
|
||||
│ ├── hetzner/ # Kubernetes Hetzner (15 fichiers numérotés)
|
||||
│ └── STRIPE_SETUP.md # Configuration Stripe
|
||||
│
|
||||
├── testing/ # Tests
|
||||
├── csv-system/ # Système CSV (format, calcul prix)
|
||||
├── carrier-portal/ # Portail carrier (recherche API)
|
||||
├── api-access/ # Documentation accès API
|
||||
├── backend/ # Notes backend (cleanup, MinIO)
|
||||
└── archive/ # Rapports de sprint archivés
|
||||
├── phases/ # Historique phases 1-4
|
||||
└── debug/ # Notes de debug résolues
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture & Development Guidelines
|
||||
## Commandes essentielles
|
||||
|
||||
### Core Architecture
|
||||
```bash
|
||||
# Démarrer
|
||||
docker-compose up -d
|
||||
npm run install:all
|
||||
cd apps/backend && npm run migration:run && cd ../..
|
||||
npm run backend:dev # http://localhost:4000
|
||||
npm run frontend:dev # http://localhost:3000
|
||||
|
||||
- **[CLAUDE.md](CLAUDE.md)** 🏗️ - **START HERE FOR ARCHITECTURE**
|
||||
- Complete hexagonal architecture guide
|
||||
- Domain/Application/Infrastructure layers
|
||||
- Ports & Adapters pattern
|
||||
- Naming conventions
|
||||
- Testing strategy
|
||||
- Common pitfalls
|
||||
- Complete examples (476 lines)
|
||||
# Tests
|
||||
npm run backend:test
|
||||
npm run frontend:test
|
||||
|
||||
### Component-Specific Documentation
|
||||
|
||||
- **[apps/backend/README.md](apps/backend/README.md)** - Backend (NestJS + Hexagonal)
|
||||
- Architecture details
|
||||
- Available scripts
|
||||
- API endpoints
|
||||
- Testing guide
|
||||
- Hexagonal architecture DOs and DON'Ts
|
||||
|
||||
- **[apps/frontend/README.md](apps/frontend/README.md)** - Frontend (Next.js 14)
|
||||
- Tech stack
|
||||
- Project structure
|
||||
- API integration
|
||||
- Forms & validation
|
||||
- Testing guide
|
||||
# Qualité
|
||||
npm run format
|
||||
npm run backend:lint && npm run frontend:lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technical Documentation
|
||||
|
||||
### Configuration Files
|
||||
|
||||
**Root Level**:
|
||||
- `package.json` - Workspace configuration
|
||||
- `.gitignore` - Git ignore rules
|
||||
- `.prettierrc` - Code formatting
|
||||
- `docker-compose.yml` - PostgreSQL + Redis
|
||||
- `tsconfig.json` - TypeScript configuration (per app)
|
||||
|
||||
**Backend** (`apps/backend/`):
|
||||
- `package.json` - Backend dependencies
|
||||
- `tsconfig.json` - TypeScript strict mode + path aliases
|
||||
- `nest-cli.json` - NestJS CLI configuration
|
||||
- `.eslintrc.js` - ESLint rules
|
||||
- `.env.example` - Environment variables template
|
||||
|
||||
**Frontend** (`apps/frontend/`):
|
||||
- `package.json` - Frontend dependencies
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `next.config.js` - Next.js configuration
|
||||
- `tailwind.config.ts` - Tailwind CSS theme
|
||||
- `postcss.config.js` - PostCSS configuration
|
||||
- `.env.example` - Environment variables template
|
||||
|
||||
### CI/CD
|
||||
|
||||
**GitHub Actions** (`.github/workflows/`):
|
||||
- `ci.yml` - Continuous Integration
|
||||
- Lint & format check
|
||||
- Unit tests (backend + frontend)
|
||||
- E2E tests
|
||||
- Build verification
|
||||
|
||||
- `security.yml` - Security Audit
|
||||
- npm audit
|
||||
- Dependency review
|
||||
|
||||
**Templates**:
|
||||
- `.github/pull_request_template.md` - PR template with hexagonal architecture checklist
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation by Use Case
|
||||
|
||||
### I want to...
|
||||
|
||||
**...get started quickly**
|
||||
1. [QUICK-START.md](QUICK-START.md) - 5-minute setup
|
||||
2. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Detailed steps
|
||||
3. [NEXT-STEPS.md](NEXT-STEPS.md) - Begin development
|
||||
|
||||
**...understand the architecture**
|
||||
1. [CLAUDE.md](CLAUDE.md) - Complete hexagonal architecture guide
|
||||
2. [apps/backend/README.md](apps/backend/README.md) - Backend specifics
|
||||
3. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - See what's implemented
|
||||
|
||||
**...know what to build next**
|
||||
1. [TODO.md](TODO.md) - Full roadmap
|
||||
2. [NEXT-STEPS.md](NEXT-STEPS.md) - Immediate next tasks
|
||||
3. [PRD.md](PRD.md) - Business requirements
|
||||
|
||||
**...understand the business context**
|
||||
1. [PRD.md](PRD.md) - Product requirements
|
||||
2. [README.md](README.md) - Project overview
|
||||
3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Executive summary
|
||||
|
||||
**...fix an installation issue**
|
||||
1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section
|
||||
2. [QUICK-START.md](QUICK-START.md) - Common issues
|
||||
3. [README.md](README.md) - Basic setup
|
||||
|
||||
**...write code following best practices**
|
||||
1. [CLAUDE.md](CLAUDE.md) - Architecture guidelines (READ THIS FIRST)
|
||||
2. [apps/backend/README.md](apps/backend/README.md) - Backend DOs and DON'Ts
|
||||
3. [TODO.md](TODO.md) - Task specifications and acceptance criteria
|
||||
|
||||
**...run tests**
|
||||
1. [apps/backend/README.md](apps/backend/README.md) - Testing section
|
||||
2. [apps/frontend/README.md](apps/frontend/README.md) - Testing section
|
||||
3. [CLAUDE.md](CLAUDE.md) - Testing strategy
|
||||
|
||||
**...deploy to production**
|
||||
1. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist
|
||||
2. [apps/backend/.env.example](apps/backend/.env.example) - All required variables
|
||||
3. `.github/workflows/ci.yml` - CI/CD pipeline
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation by Role
|
||||
|
||||
### For Developers
|
||||
|
||||
**Must Read**:
|
||||
1. [CLAUDE.md](CLAUDE.md) - Architecture principles
|
||||
2. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md)
|
||||
3. [TODO.md](TODO.md) - Current sprint tasks
|
||||
|
||||
**Reference**:
|
||||
- [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup issues
|
||||
- [PRD.md](PRD.md) - Business context
|
||||
|
||||
### For Architects
|
||||
|
||||
**Must Read**:
|
||||
1. [CLAUDE.md](CLAUDE.md) - Complete architecture
|
||||
2. [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Implementation details
|
||||
3. [PRD.md](PRD.md) - Technical requirements
|
||||
|
||||
**Reference**:
|
||||
- [TODO.md](TODO.md) - Technical roadmap
|
||||
- [apps/backend/README.md](apps/backend/README.md) - Backend architecture
|
||||
|
||||
### For Project Managers
|
||||
|
||||
**Must Read**:
|
||||
1. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Status overview
|
||||
2. [TODO.md](TODO.md) - Complete roadmap
|
||||
3. [PRD.md](PRD.md) - Requirements & KPIs
|
||||
|
||||
**Reference**:
|
||||
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Detailed completion report
|
||||
- [README.md](README.md) - Project overview
|
||||
|
||||
### For DevOps
|
||||
|
||||
**Must Read**:
|
||||
1. [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Setup guide
|
||||
2. [docker-compose.yml](docker-compose.yml) - Infrastructure
|
||||
3. `.github/workflows/` - CI/CD pipelines
|
||||
|
||||
**Reference**:
|
||||
- [apps/backend/.env.example](apps/backend/.env.example) - Environment variables
|
||||
- [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) - Security checklist
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ Complete File List
|
||||
|
||||
### Documentation (11 files)
|
||||
|
||||
| File | Purpose | Length |
|
||||
|------|---------|--------|
|
||||
| [README.md](README.md) | Project overview | Medium |
|
||||
| [CLAUDE.md](CLAUDE.md) | Architecture guide | Long (476 lines) |
|
||||
| [PRD.md](PRD.md) | Product requirements | Long (352 lines) |
|
||||
| [TODO.md](TODO.md) | 30-week roadmap | Very Long (1000+ lines) |
|
||||
| [QUICK-START.md](QUICK-START.md) | 5-minute setup | Short |
|
||||
| [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) | Detailed setup | Medium |
|
||||
| [NEXT-STEPS.md](NEXT-STEPS.md) | What's next | Medium |
|
||||
| [SPRINT-0-FINAL.md](SPRINT-0-FINAL.md) | Sprint 0 report | Long |
|
||||
| [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) | Executive summary | Medium |
|
||||
| [SPRINT-0-COMPLETE.md](SPRINT-0-COMPLETE.md) | Technical checklist | Short |
|
||||
| [INDEX.md](INDEX.md) | This file | Medium |
|
||||
|
||||
### App-Specific (2 files)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| [apps/backend/README.md](apps/backend/README.md) | Backend guide |
|
||||
| [apps/frontend/README.md](apps/frontend/README.md) | Frontend guide |
|
||||
|
||||
### Configuration (10+ files)
|
||||
|
||||
Root, backend, and frontend configuration files (package.json, tsconfig.json, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 📊 Documentation Statistics
|
||||
|
||||
- **Total Documentation Files**: 13
|
||||
- **Total Lines**: ~4,000+
|
||||
- **Coverage**: Setup, Architecture, Development, Testing, Deployment
|
||||
- **Last Updated**: October 7, 2025
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Reading Path
|
||||
|
||||
### For New Team Members (Day 1)
|
||||
|
||||
**Morning** (2 hours):
|
||||
1. [README.md](README.md) - 10 min
|
||||
2. [QUICK-START.md](QUICK-START.md) - 30 min (includes setup)
|
||||
3. [CLAUDE.md](CLAUDE.md) - 60 min (comprehensive architecture)
|
||||
4. [PRD.md](PRD.md) - 20 min (business context)
|
||||
|
||||
**Afternoon** (2 hours):
|
||||
5. [apps/backend/README.md](apps/backend/README.md) OR [apps/frontend/README.md](apps/frontend/README.md) - 30 min
|
||||
6. [TODO.md](TODO.md) - Current sprint section - 30 min
|
||||
7. [NEXT-STEPS.md](NEXT-STEPS.md) - 30 min
|
||||
8. Start coding! 🚀
|
||||
|
||||
### For Code Review (30 minutes)
|
||||
|
||||
1. [CLAUDE.md](CLAUDE.md) - Hexagonal architecture section
|
||||
2. [apps/backend/README.md](apps/backend/README.md) - DOs and DON'Ts
|
||||
3. [TODO.md](TODO.md) - Acceptance criteria for the feature
|
||||
|
||||
### For Sprint Planning (1 hour)
|
||||
|
||||
1. [TODO.md](TODO.md) - Next sprint tasks
|
||||
2. [PRD.md](PRD.md) - Requirements for the module
|
||||
3. [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md) - Current status
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Quick Reference
|
||||
|
||||
### Common Questions
|
||||
|
||||
**Q: How do I get started?**
|
||||
A: [QUICK-START.md](QUICK-START.md)
|
||||
|
||||
**Q: What is hexagonal architecture?**
|
||||
A: [CLAUDE.md](CLAUDE.md) - Complete guide with examples
|
||||
|
||||
**Q: What should I build next?**
|
||||
A: [NEXT-STEPS.md](NEXT-STEPS.md) then [TODO.md](TODO.md)
|
||||
|
||||
**Q: How do I run tests?**
|
||||
A: [apps/backend/README.md](apps/backend/README.md) or [apps/frontend/README.md](apps/frontend/README.md)
|
||||
|
||||
**Q: Where are the business requirements?**
|
||||
A: [PRD.md](PRD.md)
|
||||
|
||||
**Q: What's the project status?**
|
||||
A: [SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)
|
||||
|
||||
**Q: Installation failed, what do I do?**
|
||||
A: [INSTALLATION-STEPS.md](INSTALLATION-STEPS.md) - Troubleshooting section
|
||||
|
||||
**Q: Can I change the database/framework?**
|
||||
A: Yes! That's the point of hexagonal architecture. See [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
If you can't find what you need:
|
||||
|
||||
1. **Check this index** - Use Ctrl+F to search
|
||||
2. **Read CLAUDE.md** - Covers 90% of architecture questions
|
||||
3. **Check TODO.md** - Has detailed task specifications
|
||||
4. **Open an issue** - If documentation is unclear or missing
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Happy Reading!
|
||||
|
||||
All documentation is up-to-date as of Sprint 0 completion.
|
||||
|
||||
**Quick Links**:
|
||||
- 🚀 [Get Started](QUICK-START.md)
|
||||
- 🏗️ [Architecture](CLAUDE.md)
|
||||
- 📅 [Roadmap](TODO.md)
|
||||
- 📋 [Requirements](PRD.md)
|
||||
|
||||
---
|
||||
|
||||
*Xpeditis MVP - Maritime Freight Booking Platform*
|
||||
*Documentation Index - October 7, 2025*
|
||||
*Dernière mise à jour : Mai 2026*
|
||||
|
||||
307
README.md
307
README.md
@ -1,206 +1,151 @@
|
||||
# Xpeditis - Maritime Freight Booking Platform
|
||||
# Xpeditis — Maritime Freight Booking Platform
|
||||
|
||||
**Xpeditis** is a B2B SaaS platform for freight forwarders to search, compare, and book maritime freight in real-time.
|
||||
Plateforme B2B SaaS permettant aux transitaires de rechercher, comparer et réserver du fret maritime en temps réel.
|
||||
|
||||
---
|
||||
|
||||
## ⭐ **[START HERE](START-HERE.md)** ⭐
|
||||
|
||||
**New to the project?** Read **[START-HERE.md](START-HERE.md)** - Get running in 10 minutes!
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20.0.0
|
||||
- npm >= 10.0.0
|
||||
- Docker & Docker Compose
|
||||
- PostgreSQL 15+
|
||||
- Redis 7+
|
||||
|
||||
### Installation
|
||||
## Démarrage rapide
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
# 1. Installer les dépendances
|
||||
npm run install:all
|
||||
|
||||
# Start infrastructure (PostgreSQL + Redis)
|
||||
# 2. Démarrer l'infrastructure (PostgreSQL + Redis + MinIO)
|
||||
docker-compose up -d
|
||||
|
||||
# Setup environment variables
|
||||
# 3. Configurer l'environnement
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
cp apps/frontend/.env.example apps/frontend/.env
|
||||
cp apps/frontend/.env.example apps/frontend/.env.local
|
||||
|
||||
# Run database migrations
|
||||
npm run backend:migrate
|
||||
# 4. Exécuter les migrations
|
||||
cd apps/backend && npm run migration:run && cd ../..
|
||||
|
||||
# Start backend (development)
|
||||
npm run backend:dev
|
||||
|
||||
# Start frontend (development)
|
||||
npm run frontend:dev
|
||||
# 5. Démarrer les serveurs
|
||||
npm run backend:dev # http://localhost:4000 · Swagger: /api/docs
|
||||
npm run frontend:dev # http://localhost:3000
|
||||
```
|
||||
|
||||
### Access Points
|
||||
---
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:4000
|
||||
- **API Documentation**: http://localhost:4000/api/docs
|
||||
|
||||
## 📁 Project Structure
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
xpeditis/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API (Hexagonal Architecture)
|
||||
│ ├── backend/ # NestJS 10 — Architecture hexagonale
|
||||
│ │ └── src/
|
||||
│ │ ├── domain/ # Pure business logic
|
||||
│ │ ├── application/ # Controllers & DTOs
|
||||
│ │ └── infrastructure/ # External adapters
|
||||
│ │ ├── domain/ # Logique métier pure (TypeScript)
|
||||
│ │ ├── application/ # Controllers, DTOs, Guards
|
||||
│ │ └── infrastructure/ # TypeORM, Redis, S3, Email, Stripe
|
||||
│ └── frontend/ # Next.js 14 App Router
|
||||
├── packages/
|
||||
│ ├── shared-types/ # Shared TypeScript types
|
||||
│ └── domain/ # Shared domain logic
|
||||
└── infra/ # Infrastructure configs
|
||||
│ ├── app/[locale]/ # Routing i18n (fr, en)
|
||||
│ └── src/ # Components, hooks, lib/api
|
||||
├── docker-compose.yml # PostgreSQL 15 + Redis 7 + MinIO
|
||||
└── docs/ # Documentation complète
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This project follows **Hexagonal Architecture** (Ports & Adapters) principles:
|
||||
|
||||
- **Domain Layer**: Pure business logic, no external dependencies
|
||||
- **Application Layer**: Use cases, controllers, DTOs
|
||||
- **Infrastructure Layer**: Database, external APIs, cache, email, storage
|
||||
|
||||
See [CLAUDE.md](CLAUDE.md) for detailed architecture guidelines.
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
npm run backend:dev # Start dev server
|
||||
npm run backend:test # Run tests
|
||||
npm run backend:test:watch # Run tests in watch mode
|
||||
npm run backend:test:cov # Generate coverage report
|
||||
npm run backend:lint # Lint code
|
||||
npm run backend:build # Build for production
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
npm run frontend:dev # Start dev server
|
||||
npm run frontend:build # Build for production
|
||||
npm run frontend:test # Run tests
|
||||
npm run frontend:lint # Lint code
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Getting Started
|
||||
- **[QUICK-START.md](QUICK-START.md)** ⚡ - Get running in 5 minutes
|
||||
- **[INSTALLATION-STEPS.md](INSTALLATION-STEPS.md)** 📦 - Detailed installation guide
|
||||
- **[NEXT-STEPS.md](NEXT-STEPS.md)** 🚀 - What to do after setup
|
||||
|
||||
### Architecture & Guidelines
|
||||
- **[CLAUDE.md](CLAUDE.md)** 🏗️ - Hexagonal architecture guidelines (complete)
|
||||
- **[apps/backend/README.md](apps/backend/README.md)** - Backend documentation
|
||||
- **[apps/frontend/README.md](apps/frontend/README.md)** - Frontend documentation
|
||||
|
||||
### Project Planning
|
||||
- **[PRD.md](PRD.md)** 📋 - Product Requirements Document
|
||||
- **[TODO.md](TODO.md)** 📅 - 30-week development roadmap
|
||||
- **[SPRINT-0-FINAL.md](SPRINT-0-FINAL.md)** ✅ - Sprint 0 completion report
|
||||
- **[SPRINT-0-SUMMARY.md](SPRINT-0-SUMMARY.md)** 📊 - Executive summary
|
||||
|
||||
### API Documentation
|
||||
- **[API Docs](http://localhost:4000/api/docs)** 📖 - OpenAPI/Swagger (when running)
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm run test:all
|
||||
|
||||
# Run backend tests
|
||||
npm run backend:test
|
||||
|
||||
# Run frontend tests
|
||||
npm run frontend:test
|
||||
|
||||
# E2E tests (after implementation)
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- All passwords hashed with bcrypt (12 rounds minimum)
|
||||
- JWT tokens (access: 15min, refresh: 7 days)
|
||||
- HTTPS/TLS 1.2+ enforced
|
||||
- OWASP Top 10 protection
|
||||
- Rate limiting on all endpoints
|
||||
- CSRF protection
|
||||
|
||||
## 📊 Tech Stack
|
||||
|
||||
### Backend
|
||||
- **Framework**: NestJS 10+
|
||||
- **Language**: TypeScript 5+
|
||||
- **Database**: PostgreSQL 15+
|
||||
- **Cache**: Redis 7+
|
||||
- **ORM**: TypeORM
|
||||
- **Testing**: Jest, Supertest
|
||||
- **API Docs**: Swagger/OpenAPI
|
||||
|
||||
### Frontend
|
||||
- **Framework**: Next.js 14+ (App Router)
|
||||
- **Language**: TypeScript 5+
|
||||
- **Styling**: Tailwind CSS
|
||||
- **UI Components**: shadcn/ui
|
||||
- **State**: React Query (TanStack Query)
|
||||
- **Forms**: React Hook Form + Zod
|
||||
- **Testing**: Jest, React Testing Library, Playwright
|
||||
|
||||
## 🚢 Carrier Integrations
|
||||
|
||||
MVP supports the following maritime carriers:
|
||||
|
||||
- ✅ Maersk
|
||||
- ✅ MSC
|
||||
- ✅ CMA CGM
|
||||
- ✅ Hapag-Lloyd
|
||||
- ✅ ONE (Ocean Network Express)
|
||||
|
||||
## 📈 Monitoring & Logging
|
||||
|
||||
- **Logging**: Winston / Pino
|
||||
- **Error Tracking**: Sentry
|
||||
- **APM**: Application Performance Monitoring
|
||||
- **Metrics**: Prometheus (planned)
|
||||
|
||||
## 🔧 Environment Variables
|
||||
|
||||
See `.env.example` files in each app for required environment variables.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Create a feature branch
|
||||
2. Make your changes
|
||||
3. Write tests
|
||||
4. Run linting and formatting
|
||||
5. Submit a pull request
|
||||
|
||||
## 📝 License
|
||||
|
||||
Proprietary - All rights reserved
|
||||
|
||||
## 👥 Team
|
||||
|
||||
Built with ❤️ by the Xpeditis team
|
||||
|
||||
---
|
||||
|
||||
For detailed implementation guidelines, see [CLAUDE.md](CLAUDE.md).
|
||||
## Documentation
|
||||
|
||||
| Sujet | Fichier |
|
||||
|-------|---------|
|
||||
| Index complet | [docs/README.md](docs/README.md) |
|
||||
| Architecture hexagonale + conventions | [CLAUDE.md](CLAUDE.md) |
|
||||
| Vue d'ensemble système | [docs/architecture/overview.md](docs/architecture/overview.md) |
|
||||
| Schéma BDD (21 tables) | [docs/architecture/database.md](docs/architecture/database.md) |
|
||||
| Démarrage rapide | [docs/getting-started/quick-start.md](docs/getting-started/quick-start.md) |
|
||||
|
||||
---
|
||||
|
||||
## Commandes de développement
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
npm run backend:dev # Serveur avec hot-reload
|
||||
npm run backend:test # Tests unitaires Jest
|
||||
npm run backend:lint # ESLint
|
||||
npm run backend:build # Build production
|
||||
|
||||
# Frontend
|
||||
npm run frontend:dev # Serveur avec hot-reload
|
||||
npm run frontend:test # Tests unitaires Jest
|
||||
npm run frontend:lint # ESLint
|
||||
cd apps/frontend && npm run test:e2e # Playwright E2E
|
||||
|
||||
# Qualité
|
||||
npm run format # Prettier (tous les fichiers)
|
||||
|
||||
# Base de données
|
||||
cd apps/backend
|
||||
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/NomMigration
|
||||
npm run migration:run
|
||||
npm run migration:revert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
|
||||
### Backend
|
||||
|
||||
| Composant | Technologie |
|
||||
|-----------|-------------|
|
||||
| Framework | NestJS 10 + TypeScript 5 (strict) |
|
||||
| Base de données | PostgreSQL 15 + TypeORM |
|
||||
| Cache | Redis 7 (ioredis) |
|
||||
| Auth | JWT (15min) + Refresh + OAuth2 + API Keys (Argon2) |
|
||||
| Temps réel | Socket.IO |
|
||||
| Email | Nodemailer + MJML |
|
||||
| Paiements | Stripe |
|
||||
| Stockage | S3/MinIO |
|
||||
| Logging | nestjs-pino |
|
||||
| Monitoring | Sentry |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Composant | Technologie |
|
||||
|-----------|-------------|
|
||||
| Framework | Next.js 14 App Router + TypeScript |
|
||||
| Styling | Tailwind CSS + shadcn/ui (Radix UI) |
|
||||
| State serveur | TanStack Query v5 |
|
||||
| Tables | TanStack Table v8 + Virtual |
|
||||
| Formulaires | react-hook-form + zod |
|
||||
| Temps réel | Socket.IO client |
|
||||
| i18n | next-intl (fr, en) |
|
||||
| Graphiques | recharts |
|
||||
|
||||
---
|
||||
|
||||
## Carriers intégrés
|
||||
|
||||
| Carrier | Code | Statut |
|
||||
|---------|------|--------|
|
||||
| Maersk | MAEU | Connecteur API |
|
||||
| MSC | MSCU | Connecteur API |
|
||||
| CMA CGM | CMDU | Connecteur API |
|
||||
| Hapag-Lloyd | HLCU | Connecteur API |
|
||||
| ONE | ONEY | Connecteur API |
|
||||
| SSC Consolidation | — | CSV |
|
||||
| ECU Worldwide | — | CSV + API |
|
||||
| TCC Logistics | — | CSV |
|
||||
| NVO Consolidation | — | CSV |
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
- **Recherche tarifs** : FCL (carriers API + cache Redis 15min) + LCL CSV
|
||||
- **Réservation standard** : workflow 4 étapes, numéro WCM-YYYY-XXXXXX
|
||||
- **Réservation CSV + Portail Carrier** : magic link, accept/reject
|
||||
- **Dashboard** : KPI, graphiques, table interactive virtuelle
|
||||
- **Auth** : JWT, OAuth2 (Google/Microsoft), API Keys, RBAC (5 rôles)
|
||||
- **Abonnements** : Stripe (FREE/BRONZE/SILVER/GOLD/PLATINIUM)
|
||||
- **Notifications** : WebSocket temps réel + webhooks tiers
|
||||
- **GDPR** : export/suppression des données utilisateur
|
||||
- **Blog** : gestion de contenu bilingue (fr/en)
|
||||
- **Audit** : journal d'audit de toutes les actions
|
||||
|
||||
---
|
||||
|
||||
*Architecture hexagonale — NestJS 10 + Next.js 14 — PostgreSQL 15 + Redis 7*
|
||||
|
||||
@ -35,51 +35,27 @@ MICROSOFT_CALLBACK_URL=http://localhost:4000/api/v1/auth/microsoft/callback
|
||||
|
||||
# Application URL
|
||||
APP_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Email (SMTP)
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=ton-email@brevo.com
|
||||
SMTP_PASS=ta-cle-smtp-brevo
|
||||
SMTP_SECURE=false
|
||||
|
||||
# SMTP_FROM devient le fallback uniquement (chaque méthode a son propre from maintenant)
|
||||
SMTP_FROM=noreply@xpeditis.com
|
||||
SMTP_HOST=smtp-relay.brevo.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_SECURE=false
|
||||
SMTP_FROM=noreply@xpeditis.com
|
||||
|
||||
# AWS S3 / Storage (or MinIO for development)
|
||||
AWS_ACCESS_KEY_ID=your-aws-access-key
|
||||
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_REGION=us-east-1
|
||||
AWS_S3_ENDPOINT=http://localhost:9000
|
||||
# AWS_S3_ENDPOINT= # Leave empty for AWS S3
|
||||
|
||||
# Carrier APIs
|
||||
# Maersk
|
||||
MAERSK_API_KEY=your-maersk-api-key
|
||||
MAERSK_API_URL=https://api.maersk.com/v1
|
||||
|
||||
# MSC
|
||||
MSC_API_KEY=your-msc-api-key
|
||||
MSC_API_URL=https://api.msc.com/v1
|
||||
|
||||
# CMA CGM
|
||||
CMACGM_API_URL=https://api.cma-cgm.com/v1
|
||||
CMACGM_CLIENT_ID=your-cmacgm-client-id
|
||||
CMACGM_CLIENT_SECRET=your-cmacgm-client-secret
|
||||
|
||||
# Hapag-Lloyd
|
||||
HAPAG_API_URL=https://api.hapag-lloyd.com/v1
|
||||
HAPAG_API_KEY=your-hapag-api-key
|
||||
|
||||
# ONE (Ocean Network Express)
|
||||
ONE_API_URL=https://api.one-line.com/v1
|
||||
ONE_USERNAME=your-one-username
|
||||
ONE_PASSWORD=your-one-password
|
||||
|
||||
# Swagger Documentation Access (HTTP Basic Auth)
|
||||
# Leave empty to disable Swagger in production, or set both to protect with a password
|
||||
SWAGGER_USERNAME=admin
|
||||
SWAGGER_PASSWORD=change-this-strong-password
|
||||
# Swagger Documentation Access (HTTP Basic Auth — only you can access /api/docs)
|
||||
SWAGGER_USERNAME=
|
||||
SWAGGER_PASSWORD=
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS=12
|
||||
@ -92,17 +68,18 @@ RATE_LIMIT_MAX=100
|
||||
# Monitoring
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
|
||||
|
||||
# Frontend URL (for redirects)
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# Stripe (Subscriptions & Payments)
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Stripe Price IDs (create these in Stripe Dashboard)
|
||||
STRIPE_SILVER_MONTHLY_PRICE_ID=price_silver_monthly
|
||||
STRIPE_SILVER_YEARLY_PRICE_ID=price_silver_yearly
|
||||
STRIPE_GOLD_MONTHLY_PRICE_ID=price_gold_monthly
|
||||
STRIPE_GOLD_YEARLY_PRICE_ID=price_gold_yearly
|
||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=price_platinium_monthly
|
||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID=price_platinium_yearly
|
||||
# Stripe Price IDs (from Stripe Dashboard)
|
||||
STRIPE_SILVER_MONTHLY_PRICE_ID=
|
||||
STRIPE_SILVER_YEARLY_PRICE_ID=
|
||||
STRIPE_GOLD_MONTHLY_PRICE_ID=
|
||||
STRIPE_GOLD_YEARLY_PRICE_ID=
|
||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=
|
||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID=
|
||||
|
||||
@ -1,328 +0,0 @@
|
||||
# ✅ FIX: Redirection Transporteur après Accept/Reject
|
||||
|
||||
**Date**: 5 décembre 2025
|
||||
**Statut**: ✅ **CORRIGÉ ET TESTÉ**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Problème Identifié
|
||||
|
||||
**Symptôme**: Quand un transporteur clique sur "Accepter" ou "Refuser" dans l'email:
|
||||
- ❌ Pas de redirection vers le dashboard transporteur
|
||||
- ❌ Le status du booking ne change pas
|
||||
- ❌ Erreur 404 ou pas de réponse
|
||||
|
||||
**URL problématique**:
|
||||
```
|
||||
http://localhost:3000/api/v1/csv-bookings/{token}/accept
|
||||
```
|
||||
|
||||
**Cause Racine**: Les URLs dans l'email pointaient vers le **frontend** (port 3000) au lieu du **backend** (port 4000).
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Analyse du Problème
|
||||
|
||||
### Ce qui se passait AVANT (❌ Cassé)
|
||||
|
||||
1. **Email envoyé** avec URL: `http://localhost:3000/api/v1/csv-bookings/{token}/accept`
|
||||
2. **Transporteur clique** sur le lien
|
||||
3. **Frontend** (port 3000) reçoit la requête
|
||||
4. **Erreur 404** car `/api/v1/*` n'existe pas sur le frontend
|
||||
5. **Aucune redirection**, aucun traitement
|
||||
|
||||
### Workflow Attendu (✅ Correct)
|
||||
|
||||
1. **Email envoyé** avec URL: `http://localhost:4000/api/v1/csv-bookings/{token}/accept`
|
||||
2. **Transporteur clique** sur le lien
|
||||
3. **Backend** (port 4000) reçoit la requête
|
||||
4. **Backend traite**:
|
||||
- Accepte le booking
|
||||
- Crée un compte transporteur si nécessaire
|
||||
- Génère un token d'auto-login
|
||||
5. **Backend redirige** vers: `http://localhost:3000/carrier/confirmed?token={autoLoginToken}&action=accepted&bookingId={id}&new={isNew}`
|
||||
6. **Frontend** affiche la page de confirmation
|
||||
7. **Transporteur** est auto-connecté et voit son dashboard
|
||||
|
||||
---
|
||||
|
||||
## ✅ Correction Appliquée
|
||||
|
||||
### Fichier 1: `email.adapter.ts` (lignes 259-264)
|
||||
|
||||
**AVANT** (❌):
|
||||
```typescript
|
||||
const baseUrl = this.configService.get('APP_URL', 'http://localhost:3000'); // Frontend!
|
||||
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`;
|
||||
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`;
|
||||
```
|
||||
|
||||
**APRÈS** (✅):
|
||||
```typescript
|
||||
// Use BACKEND_URL if available, otherwise construct from PORT
|
||||
// The accept/reject endpoints are on the BACKEND, not the frontend
|
||||
const port = this.configService.get('PORT', '4000');
|
||||
const backendUrl = this.configService.get('BACKEND_URL', `http://localhost:${port}`);
|
||||
const acceptUrl = `${backendUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/accept`;
|
||||
const rejectUrl = `${backendUrl}/api/v1/csv-bookings/${bookingData.confirmationToken}/reject`;
|
||||
```
|
||||
|
||||
**Changements**:
|
||||
- ✅ Utilise `BACKEND_URL` ou construit à partir de `PORT`
|
||||
- ✅ URLs pointent maintenant vers `http://localhost:4000/api/v1/...`
|
||||
- ✅ Commentaires ajoutés pour clarifier
|
||||
|
||||
### Fichier 2: `app.module.ts` (lignes 39-40)
|
||||
|
||||
Ajout des variables `APP_URL` et `BACKEND_URL` au schéma de validation:
|
||||
|
||||
```typescript
|
||||
validationSchema: Joi.object({
|
||||
// ...
|
||||
APP_URL: Joi.string().uri().default('http://localhost:3000'),
|
||||
BACKEND_URL: Joi.string().uri().optional(),
|
||||
// ...
|
||||
}),
|
||||
```
|
||||
|
||||
**Pourquoi**: Pour éviter que ces variables soient supprimées par la validation Joi.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test du Workflow Complet
|
||||
|
||||
### Prérequis
|
||||
|
||||
- ✅ Backend en cours d'exécution (port 4000)
|
||||
- ✅ Frontend en cours d'exécution (port 3000)
|
||||
- ✅ MinIO en cours d'exécution
|
||||
- ✅ Email adapter initialisé
|
||||
|
||||
### Étape 1: Créer un Booking CSV
|
||||
|
||||
1. **Se connecter** au frontend: http://localhost:3000
|
||||
2. **Aller sur** la page de recherche avancée
|
||||
3. **Rechercher un tarif** et cliquer sur "Réserver"
|
||||
4. **Remplir le formulaire**:
|
||||
- Carrier email: Votre email de test (ou Mailtrap)
|
||||
- Ajouter au moins 1 document
|
||||
5. **Cliquer sur "Envoyer la demande"**
|
||||
|
||||
### Étape 2: Vérifier l'Email Reçu
|
||||
|
||||
1. **Ouvrir Mailtrap**: https://mailtrap.io/inboxes
|
||||
2. **Trouver l'email**: "Nouvelle demande de réservation - {origin} → {destination}"
|
||||
3. **Vérifier les URLs** des boutons:
|
||||
- ✅ Accepter: `http://localhost:4000/api/v1/csv-bookings/{token}/accept`
|
||||
- ✅ Refuser: `http://localhost:4000/api/v1/csv-bookings/{token}/reject`
|
||||
|
||||
**IMPORTANT**: Les URLs doivent pointer vers **port 4000** (backend), PAS port 3000!
|
||||
|
||||
### Étape 3: Tester l'Acceptation
|
||||
|
||||
1. **Copier l'URL** du bouton "Accepter" depuis l'email
|
||||
2. **Ouvrir dans le navigateur** (ou cliquer sur le bouton)
|
||||
3. **Observer**:
|
||||
- ✅ Le navigateur va d'abord vers `localhost:4000`
|
||||
- ✅ Puis redirige automatiquement vers `localhost:3000/carrier/confirmed?...`
|
||||
- ✅ Page de confirmation affichée
|
||||
- ✅ Transporteur auto-connecté
|
||||
|
||||
### Étape 4: Vérifier le Dashboard Transporteur
|
||||
|
||||
Après la redirection:
|
||||
|
||||
1. **URL attendue**:
|
||||
```
|
||||
http://localhost:3000/carrier/confirmed?token={autoLoginToken}&action=accepted&bookingId={id}&new=true
|
||||
```
|
||||
|
||||
2. **Page affichée**:
|
||||
- ✅ Message de confirmation: "Réservation acceptée avec succès!"
|
||||
- ✅ Lien vers le dashboard transporteur
|
||||
- ✅ Si nouveau compte: Message avec credentials
|
||||
|
||||
3. **Vérifier le status**:
|
||||
- Le booking doit maintenant avoir le status `ACCEPTED`
|
||||
- Visible dans le dashboard utilisateur (celui qui a créé le booking)
|
||||
|
||||
### Étape 5: Tester le Rejet
|
||||
|
||||
Répéter avec le bouton "Refuser":
|
||||
|
||||
1. **Créer un nouveau booking** (étape 1)
|
||||
2. **Cliquer sur "Refuser"** dans l'email
|
||||
3. **Vérifier**:
|
||||
- ✅ Redirection vers `/carrier/confirmed?...&action=rejected`
|
||||
- ✅ Message: "Réservation refusée"
|
||||
- ✅ Status du booking: `REJECTED`
|
||||
|
||||
---
|
||||
|
||||
## 📊 Vérifications Backend
|
||||
|
||||
### Logs Attendus lors de l'Acceptation
|
||||
|
||||
```bash
|
||||
# Monitorer les logs
|
||||
tail -f /tmp/backend-restart.log | grep -i "accept\|carrier\|booking"
|
||||
```
|
||||
|
||||
**Logs attendus**:
|
||||
```
|
||||
[CsvBookingService] Accepting booking with token: {token}
|
||||
[CarrierAuthService] Creating carrier account for email: carrier@test.com
|
||||
[CarrierAuthService] Carrier account created with ID: {carrierId}
|
||||
[CsvBookingService] Successfully linked booking {bookingId} to carrier {carrierId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Variables d'Environnement
|
||||
|
||||
### `.env` Backend
|
||||
|
||||
**Variables requises**:
|
||||
```bash
|
||||
PORT=4000 # Port du backend
|
||||
APP_URL=http://localhost:3000 # URL du frontend
|
||||
BACKEND_URL=http://localhost:4000 # URL du backend (optionnel, auto-construit si absent)
|
||||
```
|
||||
|
||||
**En production**:
|
||||
```bash
|
||||
PORT=4000
|
||||
APP_URL=https://xpeditis.com
|
||||
BACKEND_URL=https://api.xpeditis.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Problème 1: Toujours redirigé vers port 3000
|
||||
|
||||
**Cause**: Email envoyé AVANT la correction
|
||||
|
||||
**Solution**:
|
||||
1. Backend a été redémarré après la correction ✅
|
||||
2. Créer un **NOUVEAU booking** pour recevoir un email avec les bonnes URLs
|
||||
3. Les anciens bookings ont encore les anciennes URLs (port 3000)
|
||||
|
||||
---
|
||||
|
||||
### Problème 2: 404 Not Found sur /accept
|
||||
|
||||
**Cause**: Backend pas démarré ou route mal configurée
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Vérifier que le backend tourne
|
||||
curl http://localhost:4000/api/v1/health || echo "Backend not responding"
|
||||
|
||||
# Vérifier les logs backend
|
||||
tail -50 /tmp/backend-restart.log | grep -i "csv-bookings"
|
||||
|
||||
# Redémarrer le backend
|
||||
cd apps/backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Problème 3: Token Invalid
|
||||
|
||||
**Cause**: Token expiré ou booking déjà accepté/refusé
|
||||
|
||||
**Solution**:
|
||||
- Les bookings ne peuvent être acceptés/refusés qu'une seule fois
|
||||
- Si token invalide, créer un nouveau booking
|
||||
- Vérifier dans la base de données le status du booking
|
||||
|
||||
---
|
||||
|
||||
### Problème 4: Pas de redirection vers /carrier/confirmed
|
||||
|
||||
**Cause**: Frontend route manquante ou token d'auto-login invalide
|
||||
|
||||
**Vérification**:
|
||||
1. Vérifier que la route `/carrier/confirmed` existe dans le frontend
|
||||
2. Vérifier les logs backend pour voir si le token est généré
|
||||
3. Vérifier que le frontend affiche bien la page
|
||||
|
||||
---
|
||||
|
||||
## 📝 Checklist de Validation
|
||||
|
||||
- [x] Backend redémarré avec la correction
|
||||
- [x] Email adapter initialisé correctement
|
||||
- [x] Variables `APP_URL` et `BACKEND_URL` dans le schéma Joi
|
||||
- [ ] Nouveau booking créé (APRÈS la correction)
|
||||
- [ ] Email reçu avec URLs correctes (port 4000)
|
||||
- [ ] Clic sur "Accepter" → Redirection vers /carrier/confirmed
|
||||
- [ ] Status du booking changé en `ACCEPTED`
|
||||
- [ ] Dashboard transporteur accessible
|
||||
- [ ] Test "Refuser" fonctionne aussi
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Résumé des Corrections
|
||||
|
||||
| Aspect | Avant (❌) | Après (✅) |
|
||||
|--------|-----------|-----------|
|
||||
| **Email URL Accept** | `localhost:3000/api/v1/...` | `localhost:4000/api/v1/...` |
|
||||
| **Email URL Reject** | `localhost:3000/api/v1/...` | `localhost:4000/api/v1/...` |
|
||||
| **Redirection** | Aucune (404) | Vers `/carrier/confirmed` |
|
||||
| **Status booking** | Ne change pas | `ACCEPTED` ou `REJECTED` |
|
||||
| **Dashboard transporteur** | Inaccessible | Accessible avec auto-login |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Workflow Complet Corrigé
|
||||
|
||||
```
|
||||
1. Utilisateur crée booking
|
||||
└─> Backend sauvegarde booking (status: PENDING)
|
||||
└─> Backend envoie email avec URLs backend (port 4000) ✅
|
||||
|
||||
2. Transporteur clique "Accepter" dans email
|
||||
└─> Ouvre: http://localhost:4000/api/v1/csv-bookings/{token}/accept ✅
|
||||
└─> Backend traite la requête:
|
||||
├─> Change status → ACCEPTED ✅
|
||||
├─> Crée compte transporteur si nécessaire ✅
|
||||
├─> Génère token auto-login ✅
|
||||
└─> Redirige vers frontend: localhost:3000/carrier/confirmed?... ✅
|
||||
|
||||
3. Frontend affiche page confirmation
|
||||
└─> Message de succès ✅
|
||||
└─> Auto-login du transporteur ✅
|
||||
└─> Lien vers dashboard ✅
|
||||
|
||||
4. Transporteur accède à son dashboard
|
||||
└─> Voir la liste de ses bookings ✅
|
||||
└─> Gérer ses réservations ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Prochaines Étapes
|
||||
|
||||
1. **Tester immédiatement**:
|
||||
- Créer un nouveau booking (important: APRÈS le redémarrage)
|
||||
- Vérifier l'email reçu
|
||||
- Tester Accept/Reject
|
||||
|
||||
2. **Vérifier en production**:
|
||||
- Mettre à jour la variable `BACKEND_URL` dans le .env production
|
||||
- Redéployer le backend
|
||||
- Tester le workflow complet
|
||||
|
||||
3. **Documentation**:
|
||||
- Mettre à jour le guide utilisateur
|
||||
- Documenter le workflow transporteur
|
||||
|
||||
---
|
||||
|
||||
**Correction effectuée le 5 décembre 2025 par Claude Code** ✅
|
||||
|
||||
_Le système d'acceptation/rejet transporteur est maintenant 100% fonctionnel!_ 🚢✨
|
||||
@ -1,282 +0,0 @@
|
||||
# 🔍 Diagnostic Complet - Workflow CSV Booking
|
||||
|
||||
**Date**: 5 décembre 2025
|
||||
**Problème**: Le workflow d'envoi de demande de booking ne fonctionne pas
|
||||
|
||||
---
|
||||
|
||||
## ✅ Vérifications Effectuées
|
||||
|
||||
### 1. Backend ✅
|
||||
- ✅ Backend en cours d'exécution (port 4000)
|
||||
- ✅ Configuration SMTP corrigée (variables ajoutées au schéma Joi)
|
||||
- ✅ Email adapter initialisé correctement avec DNS bypass
|
||||
- ✅ Module CsvBookingsModule importé dans app.module.ts
|
||||
- ✅ Controller CsvBookingsController bien configuré
|
||||
- ✅ Service CsvBookingService bien configuré
|
||||
- ✅ MinIO container en cours d'exécution
|
||||
- ✅ Bucket 'xpeditis-documents' existe dans MinIO
|
||||
|
||||
### 2. Frontend ✅
|
||||
- ✅ Page `/dashboard/booking/new` existe
|
||||
- ✅ Fonction `handleSubmit` bien configurée
|
||||
- ✅ FormData correctement construit avec tous les champs
|
||||
- ✅ Documents ajoutés avec le nom 'documents' (pluriel)
|
||||
- ✅ Appel API via `createCsvBooking()` qui utilise `upload()`
|
||||
- ✅ Gestion d'erreurs présente (affiche message si échec)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Points de Défaillance Possibles
|
||||
|
||||
### Scénario 1: Erreur Frontend (Browser Console)
|
||||
**Symptômes**: Le bouton "Envoyer la demande" ne fait rien, ou affiche un message d'erreur
|
||||
|
||||
**Vérification**:
|
||||
1. Ouvrir les DevTools du navigateur (F12)
|
||||
2. Aller dans l'onglet Console
|
||||
3. Cliquer sur "Envoyer la demande"
|
||||
4. Regarder les erreurs affichées
|
||||
|
||||
**Erreurs Possibles**:
|
||||
- `Failed to fetch` → Problème de connexion au backend
|
||||
- `401 Unauthorized` → Token JWT expiré
|
||||
- `400 Bad Request` → Données invalides
|
||||
- `500 Internal Server Error` → Erreur backend (voir logs)
|
||||
|
||||
---
|
||||
|
||||
### Scénario 2: Erreur Backend (Logs)
|
||||
**Symptômes**: La requête arrive au backend mais échoue
|
||||
|
||||
**Vérification**:
|
||||
```bash
|
||||
# Voir les logs backend en temps réel
|
||||
tail -f /tmp/backend-startup.log
|
||||
|
||||
# Puis créer un booking via le frontend
|
||||
```
|
||||
|
||||
**Erreurs Possibles**:
|
||||
- **Pas de logs `=== CSV Booking Request Debug ===`** → La requête n'arrive pas au controller
|
||||
- **`At least one document is required`** → Aucun fichier uploadé
|
||||
- **`User authentication failed`** → Problème de JWT
|
||||
- **`Organization ID is required`** → User sans organizationId
|
||||
- **Erreur S3/MinIO** → Upload de fichiers échoué
|
||||
- **Erreur Email** → Envoi email échoué (ne devrait plus arriver après le fix)
|
||||
|
||||
---
|
||||
|
||||
### Scénario 3: Validation Échouée
|
||||
**Symptômes**: Erreur 400 Bad Request
|
||||
|
||||
**Causes Possibles**:
|
||||
- **Port codes invalides** (origin/destination): Doivent être exactement 5 caractères (ex: NLRTM, USNYC)
|
||||
- **Email invalide** (carrierEmail): Doit être un email valide
|
||||
- **Champs numériques** (volumeCBM, weightKG, etc.): Doivent être > 0
|
||||
- **Currency invalide**: Doit être 'USD' ou 'EUR'
|
||||
- **Pas de documents**: Au moins 1 fichier requis
|
||||
|
||||
---
|
||||
|
||||
### Scénario 4: CORS ou Network
|
||||
**Symptômes**: Erreur CORS ou network error
|
||||
|
||||
**Vérification**:
|
||||
1. Ouvrir DevTools → Network tab
|
||||
2. Créer un booking
|
||||
3. Regarder la requête POST vers `/api/v1/csv-bookings`
|
||||
4. Vérifier:
|
||||
- Status code (200/201 = OK, 4xx/5xx = erreur)
|
||||
- Response body (message d'erreur)
|
||||
- Request headers (Authorization token présent?)
|
||||
|
||||
**Solutions**:
|
||||
- Backend et frontend doivent tourner simultanément
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Backend: `http://localhost:4000`
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests à Effectuer
|
||||
|
||||
### Test 1: Vérifier que le Backend Reçoit la Requête
|
||||
|
||||
1. **Ouvrir un terminal et monitorer les logs**:
|
||||
```bash
|
||||
tail -f /tmp/backend-startup.log | grep -i "csv\|booking\|error"
|
||||
```
|
||||
|
||||
2. **Dans le navigateur**:
|
||||
- Aller sur: http://localhost:3000/dashboard/booking/new?rateData=%7B%22companyName%22%3A%22Test%20Carrier%22%2C%22companyEmail%22%3A%22carrier%40test.com%22%2C%22origin%22%3A%22NLRTM%22%2C%22destination%22%3A%22USNYC%22%2C%22containerType%22%3A%22LCL%22%2C%22priceUSD%22%3A1000%2C%22priceEUR%22%3A900%2C%22primaryCurrency%22%3A%22USD%22%2C%22transitDays%22%3A22%7D&volumeCBM=2.88&weightKG=1500&palletCount=3
|
||||
- Ajouter au moins 1 document
|
||||
- Cliquer sur "Envoyer la demande"
|
||||
|
||||
3. **Dans les logs, vous devriez voir**:
|
||||
```
|
||||
=== CSV Booking Request Debug ===
|
||||
req.user: { id: '...', organizationId: '...' }
|
||||
req.body: { carrierName: 'Test Carrier', ... }
|
||||
files: 1
|
||||
================================
|
||||
Creating CSV booking for user ...
|
||||
Uploaded 1 documents for booking ...
|
||||
CSV booking created with ID: ...
|
||||
Email sent to carrier: carrier@test.com
|
||||
Notification created for user ...
|
||||
```
|
||||
|
||||
4. **Si vous NE voyez PAS ces logs** → La requête n'arrive pas au backend. Vérifier:
|
||||
- Frontend connecté et JWT valide
|
||||
- Backend en cours d'exécution
|
||||
- Network tab du navigateur pour voir l'erreur exacte
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Vérifier le Browser Console
|
||||
|
||||
1. **Ouvrir DevTools** (F12)
|
||||
2. **Aller dans Console**
|
||||
3. **Créer un booking**
|
||||
4. **Regarder les erreurs**:
|
||||
- Si erreur affichée → noter le message exact
|
||||
- Si aucune erreur → le problème est silencieux (voir Network tab)
|
||||
|
||||
---
|
||||
|
||||
### Test 3: Vérifier Network Tab
|
||||
|
||||
1. **Ouvrir DevTools** (F12)
|
||||
2. **Aller dans Network**
|
||||
3. **Créer un booking**
|
||||
4. **Trouver la requête** `POST /api/v1/csv-bookings`
|
||||
5. **Vérifier**:
|
||||
- Status: Doit être 200 ou 201
|
||||
- Request Payload: Tous les champs présents?
|
||||
- Response: Message d'erreur?
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Solutions par Erreur
|
||||
|
||||
### Erreur: "At least one document is required"
|
||||
**Cause**: Aucun fichier n'a été uploadé
|
||||
|
||||
**Solution**:
|
||||
- Vérifier que vous avez bien sélectionné au moins 1 fichier
|
||||
- Vérifier que le fichier est dans les formats acceptés (PDF, DOC, DOCX, JPG, PNG)
|
||||
- Vérifier que le fichier fait moins de 5MB
|
||||
|
||||
---
|
||||
|
||||
### Erreur: "User authentication failed"
|
||||
**Cause**: Token JWT invalide ou expiré
|
||||
|
||||
**Solution**:
|
||||
1. Se déconnecter
|
||||
2. Se reconnecter
|
||||
3. Réessayer
|
||||
|
||||
---
|
||||
|
||||
### Erreur: "Organization ID is required"
|
||||
**Cause**: L'utilisateur n'a pas d'organizationId
|
||||
|
||||
**Solution**:
|
||||
1. Vérifier dans la base de données que l'utilisateur a bien un `organizationId`
|
||||
2. Si non, assigner une organization à l'utilisateur
|
||||
|
||||
---
|
||||
|
||||
### Erreur: S3/MinIO Upload Failed
|
||||
**Cause**: Impossible d'uploader vers MinIO
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Vérifier que MinIO tourne
|
||||
docker ps | grep minio
|
||||
|
||||
# Si non, le démarrer
|
||||
docker-compose up -d
|
||||
|
||||
# Vérifier que le bucket existe
|
||||
cd apps/backend
|
||||
node setup-minio-bucket.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Erreur: Email Failed (ne devrait plus arriver)
|
||||
**Cause**: Envoi email échoué
|
||||
|
||||
**Solution**:
|
||||
- Vérifier que les variables SMTP sont dans le schéma Joi (déjà corrigé ✅)
|
||||
- Tester l'envoi d'email: `node test-smtp-simple.js`
|
||||
|
||||
---
|
||||
|
||||
## 📊 Checklist de Diagnostic
|
||||
|
||||
Cocher au fur et à mesure:
|
||||
|
||||
- [ ] Backend en cours d'exécution (port 4000)
|
||||
- [ ] Frontend en cours d'exécution (port 3000)
|
||||
- [ ] MinIO en cours d'exécution (port 9000)
|
||||
- [ ] Bucket 'xpeditis-documents' existe
|
||||
- [ ] Variables SMTP configurées
|
||||
- [ ] Email adapter initialisé (logs backend)
|
||||
- [ ] Utilisateur connecté au frontend
|
||||
- [ ] Token JWT valide (pas expiré)
|
||||
- [ ] Browser console sans erreurs
|
||||
- [ ] Network tab montre requête POST envoyée
|
||||
- [ ] Logs backend montrent "CSV Booking Request Debug"
|
||||
- [ ] Documents uploadés (au moins 1)
|
||||
- [ ] Port codes valides (5 caractères exactement)
|
||||
- [ ] Email transporteur valide
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Commandes Utiles
|
||||
|
||||
```bash
|
||||
# Redémarrer backend
|
||||
cd apps/backend
|
||||
npm run dev
|
||||
|
||||
# Vérifier logs backend
|
||||
tail -f /tmp/backend-startup.log | grep -i "csv\|booking\|error"
|
||||
|
||||
# Tester email
|
||||
cd apps/backend
|
||||
node test-smtp-simple.js
|
||||
|
||||
# Vérifier MinIO
|
||||
docker ps | grep minio
|
||||
node setup-minio-bucket.js
|
||||
|
||||
# Voir tous les endpoints
|
||||
curl http://localhost:4000/api/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Prochaines Étapes
|
||||
|
||||
1. **Effectuer les tests** ci-dessus dans l'ordre
|
||||
2. **Noter l'erreur exacte** qui apparaît (console, network, logs)
|
||||
3. **Appliquer la solution** correspondante
|
||||
4. **Réessayer**
|
||||
|
||||
Si après tous ces tests le problème persiste, partager:
|
||||
- Le message d'erreur exact (browser console)
|
||||
- Les logs backend au moment de l'erreur
|
||||
- Le status code HTTP de la requête (network tab)
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour**: 5 décembre 2025
|
||||
**Statut**:
|
||||
- ✅ Email fix appliqué
|
||||
- ✅ MinIO bucket vérifié
|
||||
- ✅ Code analysé
|
||||
- ⏳ En attente de tests utilisateur
|
||||
@ -59,7 +59,7 @@ COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/src ./src
|
||||
|
||||
# Copy startup script (includes migrations)
|
||||
COPY --chown=nestjs:nodejs startup.js ./startup.js
|
||||
COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js
|
||||
|
||||
# Create logs and uploads directories
|
||||
RUN mkdir -p /app/logs && \
|
||||
|
||||
@ -1,386 +0,0 @@
|
||||
# ✅ CORRECTION COMPLÈTE - Envoi d'Email aux Transporteurs
|
||||
|
||||
**Date**: 5 décembre 2025
|
||||
**Statut**: ✅ **CORRIGÉ**
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Problème Identifié
|
||||
|
||||
**Symptôme**: Les emails ne sont plus envoyés aux transporteurs lors de la création de bookings CSV.
|
||||
|
||||
**Cause Racine**:
|
||||
Le fix DNS implémenté dans `EMAIL_FIX_SUMMARY.md` n'était **PAS appliqué** dans le code actuel de `email.adapter.ts`. Le code utilisait la configuration standard sans contournement DNS, ce qui causait des timeouts sur certains réseaux.
|
||||
|
||||
```typescript
|
||||
// ❌ CODE PROBLÉMATIQUE (avant correction)
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host, // ← utilisait directement 'sandbox.smtp.mailtrap.io' sans contournement DNS
|
||||
port,
|
||||
secure,
|
||||
auth: { user, pass },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Solution Implémentée
|
||||
|
||||
### 1. **Correction de `email.adapter.ts`** (Lignes 25-63)
|
||||
|
||||
**Fichier modifié**: `src/infrastructure/email/email.adapter.ts`
|
||||
|
||||
```typescript
|
||||
private initializeTransporter(): void {
|
||||
const host = this.configService.get<string>('SMTP_HOST', 'localhost');
|
||||
const port = this.configService.get<number>('SMTP_PORT', 2525);
|
||||
const user = this.configService.get<string>('SMTP_USER');
|
||||
const pass = this.configService.get<string>('SMTP_PASS');
|
||||
const secure = this.configService.get<boolean>('SMTP_SECURE', false);
|
||||
|
||||
// 🔧 FIX: Contournement DNS pour Mailtrap
|
||||
// Utilise automatiquement l'IP directe quand 'mailtrap.io' est détecté
|
||||
const useDirectIP = host.includes('mailtrap.io');
|
||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS
|
||||
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: actualHost, // ← Utilise IP directe pour Mailtrap
|
||||
port,
|
||||
secure,
|
||||
auth: { user, pass },
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
servername: serverName, // ⚠️ CRITIQUE pour TLS avec IP directe
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 30000,
|
||||
dnsTimeout: 10000,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Email adapter initialized with SMTP host: ${host}:${port} (secure: ${secure})` +
|
||||
(useDirectIP ? ` [Using direct IP: ${actualHost} with servername: ${serverName}]` : '')
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Changements clés**:
|
||||
- ✅ Détection automatique de `mailtrap.io` dans le hostname
|
||||
- ✅ Utilisation de l'IP directe `3.209.246.195` au lieu du DNS
|
||||
- ✅ Configuration TLS avec `servername` pour validation du certificat
|
||||
- ✅ Timeouts optimisés (10s connection, 30s socket)
|
||||
- ✅ Logs détaillés pour debug
|
||||
|
||||
### 2. **Vérification du comportement synchrone**
|
||||
|
||||
**Fichier vérifié**: `src/application/services/csv-booking.service.ts` (Lignes 111-136)
|
||||
|
||||
Le code utilise **déjà** le comportement synchrone correct avec `await`:
|
||||
|
||||
```typescript
|
||||
// ✅ CODE CORRECT (comportement synchrone)
|
||||
try {
|
||||
await this.emailAdapter.sendCsvBookingRequest(dto.carrierEmail, {
|
||||
bookingId,
|
||||
origin: dto.origin,
|
||||
destination: dto.destination,
|
||||
// ... autres données
|
||||
confirmationToken,
|
||||
});
|
||||
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||
// Continue even if email fails - booking is already saved
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: L'email est envoyé de manière **synchrone** - le bouton attend la confirmation d'envoi avant de répondre.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests de Validation
|
||||
|
||||
### Test 1: Script de Test Nodemailer
|
||||
|
||||
Un script de test complet a été créé pour valider les 3 configurations :
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
node test-carrier-email-fix.js
|
||||
```
|
||||
|
||||
**Ce script teste**:
|
||||
1. ❌ **Test 1**: Configuration standard (peut échouer avec timeout DNS)
|
||||
2. ✅ **Test 2**: Configuration avec IP directe (doit réussir)
|
||||
3. ✅ **Test 3**: Email complet avec template HTML (doit réussir)
|
||||
|
||||
**Résultat attendu**:
|
||||
```bash
|
||||
✅ Test 2 RÉUSSI - Configuration IP directe OK
|
||||
Message ID: <unique-id>
|
||||
Response: 250 2.0.0 Ok: queued
|
||||
|
||||
✅ Test 3 RÉUSSI - Email complet avec template envoyé
|
||||
Message ID: <unique-id>
|
||||
Response: 250 2.0.0 Ok: queued
|
||||
```
|
||||
|
||||
### Test 2: Redémarrage du Backend
|
||||
|
||||
**IMPORTANT**: Le backend DOIT être redémarré pour appliquer les changements.
|
||||
|
||||
```bash
|
||||
# 1. Tuer tous les processus backend
|
||||
lsof -ti:4000 | xargs -r kill -9
|
||||
|
||||
# 2. Redémarrer proprement
|
||||
cd apps/backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Logs attendus au démarrage**:
|
||||
```bash
|
||||
✅ Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false) [Using direct IP: 3.209.246.195 with servername: smtp.mailtrap.io]
|
||||
```
|
||||
|
||||
### Test 3: Test End-to-End avec API
|
||||
|
||||
**Prérequis**:
|
||||
- Backend démarré
|
||||
- Frontend démarré (optionnel)
|
||||
- Compte Mailtrap configuré
|
||||
|
||||
**Scénario de test**:
|
||||
|
||||
1. **Créer un booking CSV** via API ou Frontend
|
||||
|
||||
```bash
|
||||
# Via API (Postman/cURL)
|
||||
POST http://localhost:4000/api/v1/csv-bookings
|
||||
Authorization: Bearer <votre-token-jwt>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Données:
|
||||
- carrierName: "Test Carrier"
|
||||
- carrierEmail: "carrier@test.com"
|
||||
- origin: "FRPAR"
|
||||
- destination: "USNYC"
|
||||
- volumeCBM: 10
|
||||
- weightKG: 500
|
||||
- palletCount: 2
|
||||
- priceUSD: 1500
|
||||
- priceEUR: 1350
|
||||
- primaryCurrency: "USD"
|
||||
- transitDays: 15
|
||||
- containerType: "20FT"
|
||||
- notes: "Test booking"
|
||||
- files: [bill_of_lading.pdf, packing_list.pdf]
|
||||
```
|
||||
|
||||
2. **Vérifier les logs backend**:
|
||||
|
||||
```bash
|
||||
# Succès attendu
|
||||
✅ [CsvBookingService] Creating CSV booking for user <userId>
|
||||
✅ [CsvBookingService] Uploaded 2 documents for booking <bookingId>
|
||||
✅ [CsvBookingService] CSV booking created with ID: <bookingId>
|
||||
✅ [EmailAdapter] Email sent to carrier@test.com: Nouvelle demande de réservation - FRPAR → USNYC
|
||||
✅ [CsvBookingService] Email sent to carrier: carrier@test.com
|
||||
✅ [CsvBookingService] Notification created for user <userId>
|
||||
```
|
||||
|
||||
3. **Vérifier Mailtrap Inbox**:
|
||||
- Connexion: https://mailtrap.io/inboxes
|
||||
- Rechercher: "Nouvelle demande de réservation - FRPAR → USNYC"
|
||||
- Vérifier: Email avec template HTML complet, boutons Accepter/Refuser
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparaison Avant/Après
|
||||
|
||||
| Critère | ❌ Avant (Cassé) | ✅ Après (Corrigé) |
|
||||
|---------|------------------|-------------------|
|
||||
| **Envoi d'emails** | 0% (timeout DNS) | 100% (IP directe) |
|
||||
| **Temps de réponse API** | ~10s (timeout) | ~2s (normal) |
|
||||
| **Logs d'erreur** | `queryA ETIMEOUT` | Aucune erreur |
|
||||
| **Configuration requise** | DNS fonctionnel | Fonctionne partout |
|
||||
| **Messages reçus** | Aucun | Tous les emails |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration Environnement
|
||||
|
||||
### Développement (`.env` actuel)
|
||||
|
||||
```bash
|
||||
SMTP_HOST=sandbox.smtp.mailtrap.io # ← Détecté automatiquement
|
||||
SMTP_PORT=2525
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=2597bd31d265eb
|
||||
SMTP_PASS=cd126234193c89
|
||||
SMTP_FROM=noreply@xpeditis.com
|
||||
```
|
||||
|
||||
**Note**: Le code détecte automatiquement `mailtrap.io` et utilise l'IP directe.
|
||||
|
||||
### Production (Recommandations)
|
||||
|
||||
#### Option 1: Mailtrap Production
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.mailtrap.io # ← Le code utilisera l'IP directe automatiquement
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=<votre-user-production>
|
||||
SMTP_PASS=<votre-pass-production>
|
||||
```
|
||||
|
||||
#### Option 2: SendGrid
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.sendgrid.net # ← Pas de contournement DNS nécessaire
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=<votre-clé-API-SendGrid>
|
||||
```
|
||||
|
||||
#### Option 3: AWS SES
|
||||
|
||||
```bash
|
||||
SMTP_HOST=email-smtp.us-east-1.amazonaws.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=<votre-access-key-id>
|
||||
SMTP_PASS=<votre-secret-access-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Problème 1: "Email sent" dans les logs mais rien dans Mailtrap
|
||||
|
||||
**Cause**: Credentials incorrects ou mauvaise inbox
|
||||
**Solution**:
|
||||
1. Vérifier `SMTP_USER` et `SMTP_PASS` dans `.env`
|
||||
2. Régénérer les credentials sur https://mailtrap.io
|
||||
3. Vérifier la bonne inbox (Development, Staging, Production)
|
||||
|
||||
### Problème 2: "queryA ETIMEOUT" persiste après correction
|
||||
|
||||
**Cause**: Backend pas redémarré ou code pas compilé
|
||||
**Solution**:
|
||||
```bash
|
||||
# Tuer tous les backends
|
||||
lsof -ti:4000 | xargs -r kill -9
|
||||
|
||||
# Nettoyer et redémarrer
|
||||
cd apps/backend
|
||||
rm -rf dist/
|
||||
npm run build
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Problème 3: "EAUTH" authentication failed
|
||||
|
||||
**Cause**: Credentials Mailtrap invalides ou expirés
|
||||
**Solution**:
|
||||
1. Se connecter à https://mailtrap.io
|
||||
2. Aller dans Email Testing > Inboxes > <votre-inbox>
|
||||
3. Copier les nouveaux credentials (SMTP Settings)
|
||||
4. Mettre à jour `.env` et redémarrer
|
||||
|
||||
### Problème 4: Email reçu mais template cassé
|
||||
|
||||
**Cause**: Template HTML mal formaté ou variables manquantes
|
||||
**Solution**:
|
||||
1. Vérifier les logs pour les données envoyées
|
||||
2. Vérifier que toutes les variables sont présentes dans `bookingData`
|
||||
3. Tester le template avec `test-carrier-email-fix.js`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de Validation Finale
|
||||
|
||||
Avant de déclarer le problème résolu, vérifier:
|
||||
|
||||
- [x] `email.adapter.ts` corrigé avec contournement DNS
|
||||
- [x] Script de test `test-carrier-email-fix.js` créé
|
||||
- [x] Configuration `.env` vérifiée (SMTP_HOST, USER, PASS)
|
||||
- [ ] Backend redémarré avec logs confirmant IP directe
|
||||
- [ ] Test nodemailer réussi (Test 2 et 3)
|
||||
- [ ] Test end-to-end: création de booking CSV
|
||||
- [ ] Email reçu dans Mailtrap inbox
|
||||
- [ ] Template HTML complet et boutons fonctionnels
|
||||
- [ ] Logs backend sans erreur `ETIMEOUT`
|
||||
- [ ] Notification créée pour l'utilisateur
|
||||
|
||||
---
|
||||
|
||||
## 📝 Fichiers Modifiés
|
||||
|
||||
| Fichier | Lignes | Description |
|
||||
|---------|--------|-------------|
|
||||
| `src/infrastructure/email/email.adapter.ts` | 25-63 | ✅ Contournement DNS avec IP directe |
|
||||
| `test-carrier-email-fix.js` | 1-285 | 🧪 Script de test email (nouveau) |
|
||||
| `EMAIL_CARRIER_FIX_COMPLETE.md` | 1-xxx | 📄 Documentation correction (ce fichier) |
|
||||
|
||||
**Fichiers vérifiés** (code correct):
|
||||
- ✅ `src/application/services/csv-booking.service.ts` (comportement synchrone avec `await`)
|
||||
- ✅ `src/infrastructure/email/templates/email-templates.ts` (template `renderCsvBookingRequest` existe)
|
||||
- ✅ `src/infrastructure/email/email.module.ts` (module correctement configuré)
|
||||
- ✅ `src/domain/ports/out/email.port.ts` (méthode `sendCsvBookingRequest` définie)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Résultat Final
|
||||
|
||||
### ✅ Problème RÉSOLU à 100%
|
||||
|
||||
**Ce qui fonctionne maintenant**:
|
||||
1. ✅ Emails aux transporteurs envoyés sans timeout DNS
|
||||
2. ✅ Template HTML complet avec boutons Accepter/Refuser
|
||||
3. ✅ Logs détaillés pour debugging
|
||||
4. ✅ Configuration robuste (fonctionne même si DNS lent)
|
||||
5. ✅ Compatible avec n'importe quel fournisseur SMTP
|
||||
6. ✅ Notifications utilisateur créées
|
||||
7. ✅ Comportement synchrone (le bouton attend l'email)
|
||||
|
||||
**Performance**:
|
||||
- Temps d'envoi: **< 2s** (au lieu de 10s timeout)
|
||||
- Taux de succès: **100%** (au lieu de 0%)
|
||||
- Compatibilité: **Tous réseaux** (même avec DNS lent)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Prochaines Étapes
|
||||
|
||||
1. **Tester immédiatement**:
|
||||
```bash
|
||||
# 1. Test nodemailer
|
||||
node apps/backend/test-carrier-email-fix.js
|
||||
|
||||
# 2. Redémarrer backend
|
||||
lsof -ti:4000 | xargs -r kill -9
|
||||
cd apps/backend && npm run dev
|
||||
|
||||
# 3. Créer un booking CSV via frontend ou API
|
||||
```
|
||||
|
||||
2. **Vérifier Mailtrap**: https://mailtrap.io/inboxes
|
||||
|
||||
3. **Si tout fonctionne**: ✅ Fermer le ticket
|
||||
|
||||
4. **Si problème persiste**:
|
||||
- Copier les logs complets
|
||||
- Exécuter `test-carrier-email-fix.js` et copier la sortie
|
||||
- Partager pour debug supplémentaire
|
||||
|
||||
---
|
||||
|
||||
**Prêt pour la production** 🚢✨
|
||||
|
||||
_Correction effectuée le 5 décembre 2025 par Claude Code_
|
||||
@ -1,275 +0,0 @@
|
||||
# ✅ EMAIL FIX COMPLETE - ROOT CAUSE RESOLVED
|
||||
|
||||
**Date**: 5 décembre 2025
|
||||
**Statut**: ✅ **RÉSOLU ET TESTÉ**
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ROOT CAUSE IDENTIFIÉE
|
||||
|
||||
**Problème**: Les emails aux transporteurs ne s'envoyaient plus après l'implémentation du Carrier Portal.
|
||||
|
||||
**Cause Racine**: Les variables d'environnement SMTP n'étaient **PAS déclarées** dans le schéma de validation Joi de ConfigModule (`app.module.ts`).
|
||||
|
||||
### Pourquoi c'était cassé?
|
||||
|
||||
NestJS ConfigModule avec un `validationSchema` Joi **supprime automatiquement** toutes les variables d'environnement qui ne sont pas explicitement déclarées dans le schéma. Le schéma original (lignes 36-50 de `app.module.ts`) ne contenait que:
|
||||
|
||||
```typescript
|
||||
validationSchema: Joi.object({
|
||||
NODE_ENV: Joi.string()...
|
||||
PORT: Joi.number()...
|
||||
DATABASE_HOST: Joi.string()...
|
||||
REDIS_HOST: Joi.string()...
|
||||
JWT_SECRET: Joi.string()...
|
||||
// ❌ AUCUNE VARIABLE SMTP DÉCLARÉE!
|
||||
})
|
||||
```
|
||||
|
||||
Résultat:
|
||||
- `SMTP_HOST` → undefined
|
||||
- `SMTP_PORT` → undefined
|
||||
- `SMTP_USER` → undefined
|
||||
- `SMTP_PASS` → undefined
|
||||
- `SMTP_FROM` → undefined
|
||||
- `SMTP_SECURE` → undefined
|
||||
|
||||
L'email adapter tentait alors de se connecter à `localhost:2525` au lieu de Mailtrap, causant des erreurs `ECONNREFUSED`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ SOLUTION IMPLÉMENTÉE
|
||||
|
||||
### 1. Ajout des variables SMTP au schéma de validation
|
||||
|
||||
**Fichier modifié**: `apps/backend/src/app.module.ts` (lignes 50-56)
|
||||
|
||||
```typescript
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema: Joi.object({
|
||||
// ... variables existantes ...
|
||||
|
||||
// ✅ NOUVEAU: SMTP Configuration
|
||||
SMTP_HOST: Joi.string().required(),
|
||||
SMTP_PORT: Joi.number().default(2525),
|
||||
SMTP_USER: Joi.string().required(),
|
||||
SMTP_PASS: Joi.string().required(),
|
||||
SMTP_FROM: Joi.string().email().default('noreply@xpeditis.com'),
|
||||
SMTP_SECURE: Joi.boolean().default(false),
|
||||
}),
|
||||
}),
|
||||
```
|
||||
|
||||
**Changements**:
|
||||
- ✅ Ajout de 6 variables SMTP au schéma Joi
|
||||
- ✅ `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` requis
|
||||
- ✅ `SMTP_PORT` avec default 2525
|
||||
- ✅ `SMTP_FROM` avec validation email
|
||||
- ✅ `SMTP_SECURE` avec default false
|
||||
|
||||
### 2. DNS Fix (Déjà présent)
|
||||
|
||||
Le DNS fix dans `email.adapter.ts` (lignes 42-45) était déjà correct depuis la correction précédente:
|
||||
|
||||
```typescript
|
||||
const useDirectIP = host.includes('mailtrap.io');
|
||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 TESTS DE VALIDATION
|
||||
|
||||
### Test 1: Backend Logs ✅
|
||||
|
||||
```bash
|
||||
[2025-12-05 13:24:59.567] INFO: Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false) [Using direct IP: 3.209.246.195 with servername: smtp.mailtrap.io]
|
||||
```
|
||||
|
||||
**Vérification**:
|
||||
- ✅ Host: sandbox.smtp.mailtrap.io:2525
|
||||
- ✅ Using direct IP: 3.209.246.195
|
||||
- ✅ Servername: smtp.mailtrap.io
|
||||
- ✅ Secure: false
|
||||
|
||||
### Test 2: SMTP Simple Test ✅
|
||||
|
||||
```bash
|
||||
$ node test-smtp-simple.js
|
||||
|
||||
Configuration:
|
||||
SMTP_HOST: sandbox.smtp.mailtrap.io ✅
|
||||
SMTP_PORT: 2525 ✅
|
||||
SMTP_USER: 2597bd31d265eb ✅
|
||||
SMTP_PASS: *** ✅
|
||||
|
||||
Test 1: Vérification de la connexion...
|
||||
✅ Connexion SMTP OK
|
||||
|
||||
Test 2: Envoi d'un email...
|
||||
✅ Email envoyé avec succès!
|
||||
Message ID: <f21d412a-3739-b5c9-62cc-b00db514d9db@xpeditis.com>
|
||||
Response: 250 2.0.0 Ok: queued
|
||||
|
||||
✅ TOUS LES TESTS RÉUSSIS - Le SMTP fonctionne!
|
||||
```
|
||||
|
||||
### Test 3: Email Flow Complet ✅
|
||||
|
||||
```bash
|
||||
$ node debug-email-flow.js
|
||||
|
||||
📊 RÉSUMÉ DES TESTS:
|
||||
Connexion SMTP: ✅ OK
|
||||
Email simple: ✅ OK
|
||||
Email transporteur: ✅ OK
|
||||
|
||||
✅ TOUS LES TESTS ONT RÉUSSI!
|
||||
Le système d'envoi d'email fonctionne correctement.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Avant/Après
|
||||
|
||||
| Critère | ❌ Avant | ✅ Après |
|
||||
|---------|----------|----------|
|
||||
| **Variables SMTP** | undefined | Chargées correctement |
|
||||
| **Connexion SMTP** | ECONNREFUSED ::1:2525 | Connecté à 3.209.246.195:2525 |
|
||||
| **Envoi email** | 0% (échec) | 100% (succès) |
|
||||
| **Backend logs** | Pas d'init SMTP | "Email adapter initialized" |
|
||||
| **Test scripts** | Tous échouent | Tous réussissent |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 VÉRIFICATION END-TO-END
|
||||
|
||||
Le backend est déjà démarré et fonctionnel. Pour tester le flux complet de création de booking avec envoi d'email:
|
||||
|
||||
### Option 1: Via l'interface web
|
||||
|
||||
1. Ouvrir http://localhost:3000
|
||||
2. Se connecter
|
||||
3. Créer un CSV booking avec l'email d'un transporteur
|
||||
4. Vérifier les logs backend:
|
||||
```
|
||||
✅ [CsvBookingService] Email sent to carrier: carrier@example.com
|
||||
```
|
||||
5. Vérifier Mailtrap: https://mailtrap.io/inboxes
|
||||
|
||||
### Option 2: Via API (cURL/Postman)
|
||||
|
||||
```bash
|
||||
POST http://localhost:4000/api/v1/csv-bookings
|
||||
Authorization: Bearer <your-jwt-token>
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
{
|
||||
"carrierName": "Test Carrier",
|
||||
"carrierEmail": "carrier@test.com",
|
||||
"origin": "FRPAR",
|
||||
"destination": "USNYC",
|
||||
"volumeCBM": 10,
|
||||
"weightKG": 500,
|
||||
"palletCount": 2,
|
||||
"priceUSD": 1500,
|
||||
"primaryCurrency": "USD",
|
||||
"transitDays": 15,
|
||||
"containerType": "20FT",
|
||||
"files": [attachment]
|
||||
}
|
||||
```
|
||||
|
||||
**Logs attendus**:
|
||||
```
|
||||
✅ [CsvBookingService] Creating CSV booking for user <userId>
|
||||
✅ [CsvBookingService] Uploaded 2 documents for booking <bookingId>
|
||||
✅ [CsvBookingService] CSV booking created with ID: <bookingId>
|
||||
✅ [EmailAdapter] Email sent to carrier@test.com
|
||||
✅ [CsvBookingService] Email sent to carrier: carrier@test.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Fichiers Modifiés
|
||||
|
||||
| Fichier | Lignes | Changement |
|
||||
|---------|--------|------------|
|
||||
| `apps/backend/src/app.module.ts` | 50-56 | ✅ Ajout variables SMTP au schéma Joi |
|
||||
| `apps/backend/src/infrastructure/email/email.adapter.ts` | 42-65 | ✅ DNS fix (déjà présent) |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 RÉSULTAT FINAL
|
||||
|
||||
### ✅ Problème RÉSOLU à 100%
|
||||
|
||||
**Ce qui fonctionne**:
|
||||
1. ✅ Variables SMTP chargées depuis `.env`
|
||||
2. ✅ Email adapter s'initialise correctement
|
||||
3. ✅ Connexion SMTP avec DNS bypass (IP directe)
|
||||
4. ✅ Envoi d'emails simples réussi
|
||||
5. ✅ Envoi d'emails avec template HTML réussi
|
||||
6. ✅ Backend démarre sans erreur
|
||||
7. ✅ Tous les tests passent
|
||||
|
||||
**Performance**:
|
||||
- Temps d'envoi: **< 2s**
|
||||
- Taux de succès: **100%**
|
||||
- Compatibilité: **Tous réseaux**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Commandes Utiles
|
||||
|
||||
### Vérifier le backend
|
||||
|
||||
```bash
|
||||
# Voir les logs en temps réel
|
||||
tail -f /tmp/backend-startup.log
|
||||
|
||||
# Vérifier que le backend tourne
|
||||
lsof -i:4000
|
||||
|
||||
# Redémarrer le backend
|
||||
lsof -ti:4000 | xargs -r kill -9
|
||||
cd apps/backend && npm run dev
|
||||
```
|
||||
|
||||
### Tester l'envoi d'emails
|
||||
|
||||
```bash
|
||||
# Test SMTP simple
|
||||
cd apps/backend
|
||||
node test-smtp-simple.js
|
||||
|
||||
# Test complet avec template
|
||||
node debug-email-flow.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de Validation
|
||||
|
||||
- [x] ConfigModule validation schema updated
|
||||
- [x] SMTP variables added to Joi schema
|
||||
- [x] Backend redémarré avec succès
|
||||
- [x] Backend logs show "Email adapter initialized"
|
||||
- [x] Test SMTP simple réussi
|
||||
- [x] Test email flow complet réussi
|
||||
- [x] Environment variables loading correctly
|
||||
- [x] DNS bypass actif (direct IP)
|
||||
- [ ] Test end-to-end via création de booking (à faire par l'utilisateur)
|
||||
- [ ] Email reçu dans Mailtrap (à vérifier par l'utilisateur)
|
||||
|
||||
---
|
||||
|
||||
**Prêt pour la production** 🚢✨
|
||||
|
||||
_Correction effectuée le 5 décembre 2025 par Claude Code_
|
||||
|
||||
**Backend Status**: ✅ Running on port 4000
|
||||
**Email System**: ✅ Fully functional
|
||||
**Next Step**: Create a CSV booking to test the complete workflow
|
||||
@ -1,295 +0,0 @@
|
||||
# 📧 Résolution Complète du Problème d'Envoi d'Emails
|
||||
|
||||
## 🔍 Problème Identifié
|
||||
|
||||
**Symptôme**: Les emails n'étaient plus envoyés aux transporteurs lors de la création de réservations CSV.
|
||||
|
||||
**Cause Racine**: Changement du comportement d'envoi d'email de SYNCHRONE à ASYNCHRONE
|
||||
- Le code original utilisait `await` pour attendre l'envoi de l'email avant de répondre
|
||||
- J'ai tenté d'optimiser avec `setImmediate()` et `void` operator (fire-and-forget)
|
||||
- **ERREUR**: L'utilisateur VOULAIT le comportement synchrone où le bouton attend la confirmation d'envoi
|
||||
- Les emails n'étaient plus envoyés car le contexte d'exécution était perdu avec les appels asynchrones
|
||||
|
||||
## ✅ Solution Implémentée
|
||||
|
||||
### **Restauration du comportement SYNCHRONE** ✨ SOLUTION FINALE
|
||||
**Fichiers modifiés**:
|
||||
- `src/application/services/csv-booking.service.ts` (lignes 111-136)
|
||||
- `src/application/services/carrier-auth.service.ts` (lignes 110-117, 287-294)
|
||||
- `src/infrastructure/email/email.adapter.ts` (configuration simplifiée)
|
||||
|
||||
```typescript
|
||||
// Utilise automatiquement l'IP 3.209.246.195 quand 'mailtrap.io' est détecté
|
||||
const useDirectIP = host.includes('mailtrap.io');
|
||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host; // Pour TLS
|
||||
|
||||
// Configuration avec IP directe + servername pour TLS
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: actualHost,
|
||||
port,
|
||||
secure: false,
|
||||
auth: { user, pass },
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
servername: serverName, // ⚠️ CRITIQUE pour TLS
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 30000,
|
||||
dnsTimeout: 10000,
|
||||
});
|
||||
```
|
||||
|
||||
**Résultat**: ✅ Test réussi - Email envoyé avec succès (Message ID: `576597e7-1a81-165d-2a46-d97c57d21daa`)
|
||||
|
||||
---
|
||||
|
||||
### 2. **Remplacement de `setImmediate()` par `void` operator**
|
||||
**Fichiers Modifiés**:
|
||||
- `src/application/services/csv-booking.service.ts` (ligne 114)
|
||||
- `src/application/services/carrier-auth.service.ts` (lignes 112, 290)
|
||||
|
||||
**Avant** (bloquant):
|
||||
```typescript
|
||||
setImmediate(() => {
|
||||
this.emailAdapter.sendCsvBookingRequest(...)
|
||||
.then(() => { ... })
|
||||
.catch(() => { ... });
|
||||
});
|
||||
```
|
||||
|
||||
**Après** (non-bloquant mais avec contexte):
|
||||
```typescript
|
||||
void this.emailAdapter.sendCsvBookingRequest(...)
|
||||
.then(() => {
|
||||
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||
});
|
||||
```
|
||||
|
||||
**Bénéfices**:
|
||||
- ✅ Réponse API ~50% plus rapide (pas d'attente d'envoi)
|
||||
- ✅ Logs des erreurs d'envoi préservés
|
||||
- ✅ Contexte NestJS maintenu (pas de perte de dépendances)
|
||||
|
||||
---
|
||||
|
||||
### 3. **Configuration `.env` Mise à Jour**
|
||||
**Fichier**: `.env`
|
||||
|
||||
```bash
|
||||
# Email (SMTP)
|
||||
# Using smtp.mailtrap.io instead of sandbox.smtp.mailtrap.io to avoid DNS timeout
|
||||
SMTP_HOST=smtp.mailtrap.io # ← Changé
|
||||
SMTP_PORT=2525
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=2597bd31d265eb
|
||||
SMTP_PASS=cd126234193c89
|
||||
SMTP_FROM=noreply@xpeditis.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **Ajout des Méthodes d'Email Transporteur**
|
||||
**Fichier**: `src/domain/ports/out/email.port.ts`
|
||||
|
||||
Ajout de 2 nouvelles méthodes à l'interface:
|
||||
- `sendCarrierAccountCreated()` - Email de création de compte avec mot de passe temporaire
|
||||
- `sendCarrierPasswordReset()` - Email de réinitialisation de mot de passe
|
||||
|
||||
**Implémentation**: `src/infrastructure/email/email.adapter.ts` (lignes 269-413)
|
||||
- Templates HTML en français
|
||||
- Boutons d'action stylisés
|
||||
- Warnings de sécurité
|
||||
- Instructions de connexion
|
||||
|
||||
---
|
||||
|
||||
## 📋 Fichiers Modifiés (Récapitulatif)
|
||||
|
||||
| Fichier | Lignes | Description |
|
||||
|---------|--------|-------------|
|
||||
| `infrastructure/email/email.adapter.ts` | 25-63 | ✨ Contournement DNS avec IP directe |
|
||||
| `infrastructure/email/email.adapter.ts` | 269-413 | Méthodes emails transporteur |
|
||||
| `application/services/csv-booking.service.ts` | 114-137 | `void` operator pour emails async |
|
||||
| `application/services/carrier-auth.service.ts` | 112-118 | `void` operator (création compte) |
|
||||
| `application/services/carrier-auth.service.ts` | 290-296 | `void` operator (reset password) |
|
||||
| `domain/ports/out/email.port.ts` | 107-123 | Interface méthodes transporteur |
|
||||
| `.env` | 42 | Changement SMTP_HOST |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests de Validation
|
||||
|
||||
### Test 1: Backend Redémarré avec Succès ✅ **RÉUSSI**
|
||||
```bash
|
||||
# Tuer tous les processus sur port 4000
|
||||
lsof -ti:4000 | xargs kill -9
|
||||
|
||||
# Démarrer le backend proprement
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Résultat**:
|
||||
```
|
||||
✅ Email adapter initialized with SMTP host: sandbox.smtp.mailtrap.io:2525 (secure: false)
|
||||
✅ Nest application successfully started
|
||||
✅ Connected to Redis at localhost:6379
|
||||
🚢 Xpeditis API Server Running on http://localhost:4000
|
||||
```
|
||||
|
||||
### Test 2: Test d'Envoi d'Email (À faire par l'utilisateur)
|
||||
1. ✅ Backend démarré avec configuration correcte
|
||||
2. Créer une réservation CSV avec transporteur via API
|
||||
3. Vérifier les logs pour: `Email sent to carrier: [email]`
|
||||
4. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Comment Tester en Production
|
||||
|
||||
### Étape 1: Créer une Réservation CSV
|
||||
```bash
|
||||
POST http://localhost:4000/api/v1/csv-bookings
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
{
|
||||
"carrierName": "Test Carrier",
|
||||
"carrierEmail": "test@example.com",
|
||||
"origin": "FRPAR",
|
||||
"destination": "USNYC",
|
||||
"volumeCBM": 10,
|
||||
"weightKG": 500,
|
||||
"palletCount": 2,
|
||||
"priceUSD": 1500,
|
||||
"priceEUR": 1300,
|
||||
"primaryCurrency": "USD",
|
||||
"transitDays": 15,
|
||||
"containerType": "20FT",
|
||||
"notes": "Test booking"
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 2: Vérifier les Logs
|
||||
Rechercher dans les logs backend:
|
||||
```bash
|
||||
# Succès
|
||||
✅ "Email sent to carrier: test@example.com"
|
||||
✅ "CSV booking request sent to test@example.com for booking <ID>"
|
||||
|
||||
# Échec (ne devrait plus arriver)
|
||||
❌ "Failed to send email to carrier: queryA ETIMEOUT"
|
||||
```
|
||||
|
||||
### Étape 3: Vérifier Mailtrap
|
||||
1. Connexion: https://mailtrap.io
|
||||
2. Inbox: "Xpeditis Development"
|
||||
3. Email: "Nouvelle demande de réservation - FRPAR → USNYC"
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance
|
||||
|
||||
### Avant (Problème)
|
||||
- ❌ Emails: **0% envoyés** (timeout DNS)
|
||||
- ⏱️ Temps réponse API: ~500ms + timeout (10s)
|
||||
- ❌ Logs: Erreurs `queryA ETIMEOUT`
|
||||
|
||||
### Après (Corrigé)
|
||||
- ✅ Emails: **100% envoyés** (IP directe)
|
||||
- ⏱️ Temps réponse API: ~200-300ms (async fire-and-forget)
|
||||
- ✅ Logs: `Email sent to carrier:`
|
||||
- 📧 Latence email: <2s (Mailtrap)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration Production
|
||||
|
||||
Pour le déploiement production, mettre à jour `.env`:
|
||||
|
||||
```bash
|
||||
# Option 1: Utiliser smtp.mailtrap.io (IP auto)
|
||||
SMTP_HOST=smtp.mailtrap.io
|
||||
SMTP_PORT=2525
|
||||
SMTP_SECURE=false
|
||||
|
||||
# Option 2: Autre fournisseur SMTP (ex: SendGrid)
|
||||
SMTP_HOST=smtp.sendgrid.net
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=apikey
|
||||
SMTP_PASS=<votre-clé-API-SendGrid>
|
||||
```
|
||||
|
||||
**Note**: Le code détecte automatiquement `mailtrap.io` et utilise l'IP. Pour d'autres fournisseurs, le DNS standard sera utilisé.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Dépannage
|
||||
|
||||
### Problème: "Email sent" dans les logs mais rien dans Mailtrap
|
||||
**Cause**: Mauvais credentials ou inbox
|
||||
**Solution**: Vérifier `SMTP_USER` et `SMTP_PASS` dans `.env`
|
||||
|
||||
### Problème: "queryA ETIMEOUT" persiste
|
||||
**Cause**: Backend pas redémarré ou code pas compilé
|
||||
**Solution**:
|
||||
```bash
|
||||
# 1. Tuer tous les backends
|
||||
lsof -ti:4000 | xargs kill -9
|
||||
|
||||
# 2. Redémarrer proprement
|
||||
cd apps/backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Problème: "EAUTH" authentication failed
|
||||
**Cause**: Credentials Mailtrap invalides
|
||||
**Solution**: Régénérer les credentials sur https://mailtrap.io
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist de Validation
|
||||
|
||||
- [x] Méthodes `sendCarrierAccountCreated` et `sendCarrierPasswordReset` implémentées
|
||||
- [x] Comportement SYNCHRONE restauré avec `await` (au lieu de setImmediate/void)
|
||||
- [x] Configuration SMTP simplifiée (pas de contournement DNS nécessaire)
|
||||
- [x] `.env` mis à jour avec `sandbox.smtp.mailtrap.io`
|
||||
- [x] Backend redémarré proprement
|
||||
- [x] Email adapter initialisé avec bonne configuration
|
||||
- [x] Server écoute sur port 4000
|
||||
- [x] Redis connecté
|
||||
- [ ] Test end-to-end avec création CSV booking ← **À TESTER PAR L'UTILISATEUR**
|
||||
- [ ] Email reçu dans Mailtrap inbox ← **À VALIDER PAR L'UTILISATEUR**
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes Techniques
|
||||
|
||||
### Pourquoi l'IP Directe Fonctionne ?
|
||||
Node.js utilise `dns.resolve()` qui peut timeout même si le système DNS fonctionne. En utilisant l'IP directe, on contourne complètement la résolution DNS.
|
||||
|
||||
### Pourquoi `servername` dans TLS ?
|
||||
Quand on utilise une IP directe, TLS ne peut pas vérifier le certificat sans le `servername`. On spécifie donc `smtp.mailtrap.io` manuellement.
|
||||
|
||||
### Alternative (Non Implémentée)
|
||||
Configurer Node.js pour utiliser Google DNS:
|
||||
```javascript
|
||||
const dns = require('dns');
|
||||
dns.setServers(['8.8.8.8', '8.8.4.4']);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Résultat Final
|
||||
|
||||
✅ **Problème résolu à 100%**
|
||||
- Emails aux transporteurs fonctionnent
|
||||
- Performance améliorée (~50% plus rapide)
|
||||
- Logs clairs et précis
|
||||
- Code robuste avec gestion d'erreurs
|
||||
|
||||
**Prêt pour la production** 🚀
|
||||
Binary file not shown.
@ -1,321 +0,0 @@
|
||||
/**
|
||||
* Script de debug pour tester le flux complet d'envoi d'email
|
||||
*
|
||||
* Ce script teste:
|
||||
* 1. Connexion SMTP
|
||||
* 2. Envoi d'un email simple
|
||||
* 3. Envoi avec le template complet
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
console.log('\n🔍 DEBUG - Flux d\'envoi d\'email transporteur\n');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 1. Afficher la configuration
|
||||
console.log('\n📋 CONFIGURATION ACTUELLE:');
|
||||
console.log('----------------------------');
|
||||
console.log('SMTP_HOST:', process.env.SMTP_HOST);
|
||||
console.log('SMTP_PORT:', process.env.SMTP_PORT);
|
||||
console.log('SMTP_SECURE:', process.env.SMTP_SECURE);
|
||||
console.log('SMTP_USER:', process.env.SMTP_USER);
|
||||
console.log('SMTP_PASS:', process.env.SMTP_PASS ? '***' + process.env.SMTP_PASS.slice(-4) : 'NON DÉFINI');
|
||||
console.log('SMTP_FROM:', process.env.SMTP_FROM);
|
||||
console.log('APP_URL:', process.env.APP_URL);
|
||||
|
||||
// 2. Vérifier les variables requises
|
||||
console.log('\n✅ VÉRIFICATION DES VARIABLES:');
|
||||
console.log('--------------------------------');
|
||||
const requiredVars = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASS'];
|
||||
const missing = requiredVars.filter(v => !process.env[v]);
|
||||
if (missing.length > 0) {
|
||||
console.error('❌ Variables manquantes:', missing.join(', '));
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ Toutes les variables requises sont présentes');
|
||||
}
|
||||
|
||||
// 3. Créer le transporter avec la même configuration que le backend
|
||||
console.log('\n🔧 CRÉATION DU TRANSPORTER:');
|
||||
console.log('----------------------------');
|
||||
|
||||
const host = process.env.SMTP_HOST;
|
||||
const port = parseInt(process.env.SMTP_PORT);
|
||||
const user = process.env.SMTP_USER;
|
||||
const pass = process.env.SMTP_PASS;
|
||||
const secure = process.env.SMTP_SECURE === 'true';
|
||||
|
||||
// Même logique que dans email.adapter.ts
|
||||
const useDirectIP = host.includes('mailtrap.io');
|
||||
const actualHost = useDirectIP ? '3.209.246.195' : host;
|
||||
const serverName = useDirectIP ? 'smtp.mailtrap.io' : host;
|
||||
|
||||
console.log('Configuration détectée:');
|
||||
console.log(' Host original:', host);
|
||||
console.log(' Utilise IP directe:', useDirectIP);
|
||||
console.log(' Host réel:', actualHost);
|
||||
console.log(' Server name (TLS):', serverName);
|
||||
console.log(' Port:', port);
|
||||
console.log(' Secure:', secure);
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: actualHost,
|
||||
port,
|
||||
secure,
|
||||
auth: {
|
||||
user,
|
||||
pass,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false,
|
||||
servername: serverName,
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
socketTimeout: 30000,
|
||||
dnsTimeout: 10000,
|
||||
});
|
||||
|
||||
// 4. Tester la connexion
|
||||
console.log('\n🔌 TEST DE CONNEXION SMTP:');
|
||||
console.log('---------------------------');
|
||||
|
||||
async function testConnection() {
|
||||
try {
|
||||
console.log('Vérification de la connexion...');
|
||||
await transporter.verify();
|
||||
console.log('✅ Connexion SMTP réussie!');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Échec de la connexion SMTP:');
|
||||
console.error(' Message:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
console.error(' Command:', error.command);
|
||||
if (error.stack) {
|
||||
console.error(' Stack:', error.stack.substring(0, 200) + '...');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Envoyer un email de test simple
|
||||
async function sendSimpleEmail() {
|
||||
console.log('\n📧 TEST 1: Email simple');
|
||||
console.log('------------------------');
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Simple - ' + new Date().toISOString(),
|
||||
text: 'Ceci est un test simple',
|
||||
html: '<h1>Test Simple</h1><p>Ceci est un test simple</p>',
|
||||
});
|
||||
|
||||
console.log('✅ Email simple envoyé avec succès!');
|
||||
console.log(' Message ID:', info.messageId);
|
||||
console.log(' Response:', info.response);
|
||||
console.log(' Accepted:', info.accepted);
|
||||
console.log(' Rejected:', info.rejected);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Échec d\'envoi email simple:');
|
||||
console.error(' Message:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Envoyer un email avec le template transporteur complet
|
||||
async function sendCarrierEmail() {
|
||||
console.log('\n📧 TEST 2: Email transporteur avec template');
|
||||
console.log('--------------------------------------------');
|
||||
|
||||
const bookingData = {
|
||||
bookingId: 'TEST-' + Date.now(),
|
||||
origin: 'FRPAR',
|
||||
destination: 'USNYC',
|
||||
volumeCBM: 15.5,
|
||||
weightKG: 1200,
|
||||
palletCount: 6,
|
||||
priceUSD: 2500,
|
||||
priceEUR: 2250,
|
||||
primaryCurrency: 'USD',
|
||||
transitDays: 18,
|
||||
containerType: '40FT',
|
||||
documents: [
|
||||
{ type: 'Bill of Lading', fileName: 'bol-test.pdf' },
|
||||
{ type: 'Packing List', fileName: 'packing-test.pdf' },
|
||||
{ type: 'Commercial Invoice', fileName: 'invoice-test.pdf' },
|
||||
],
|
||||
};
|
||||
|
||||
const baseUrl = process.env.APP_URL || 'http://localhost:3000';
|
||||
const acceptUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/accept`;
|
||||
const rejectUrl = `${baseUrl}/api/v1/csv-bookings/${bookingData.bookingId}/reject`;
|
||||
|
||||
// Template HTML (version simplifiée pour le test)
|
||||
const htmlTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nouvelle demande de réservation</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f6f8;">
|
||||
<div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);">
|
||||
<div style="background: linear-gradient(135deg, #045a8d, #00bcd4); color: #ffffff; padding: 30px 20px; text-align: center;">
|
||||
<h1 style="margin: 0; font-size: 28px;">🚢 Nouvelle demande de réservation</h1>
|
||||
<p style="margin: 5px 0 0; font-size: 14px;">Xpeditis</p>
|
||||
</div>
|
||||
<div style="padding: 30px 20px;">
|
||||
<p style="font-size: 16px;">Bonjour,</p>
|
||||
<p>Vous avez reçu une nouvelle demande de réservation via Xpeditis.</p>
|
||||
|
||||
<h2 style="color: #045a8d; border-bottom: 2px solid #00bcd4; padding-bottom: 8px;">📋 Détails du transport</h2>
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Route</td>
|
||||
<td style="padding: 12px;">${bookingData.origin} → ${bookingData.destination}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Volume</td>
|
||||
<td style="padding: 12px;">${bookingData.volumeCBM} CBM</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Poids</td>
|
||||
<td style="padding: 12px;">${bookingData.weightKG} kg</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #e0e0e0;">
|
||||
<td style="padding: 12px; font-weight: bold; color: #045a8d;">Prix</td>
|
||||
<td style="padding: 12px; font-size: 24px; font-weight: bold; color: #00aa00;">
|
||||
${bookingData.priceUSD} USD
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 6px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0; color: #045a8d;">📄 Documents fournis</h3>
|
||||
<ul style="list-style: none; padding: 0; margin: 10px 0 0;">
|
||||
${bookingData.documents.map(doc => `<li style="padding: 8px 0;">📄 <strong>${doc.type}:</strong> ${doc.fileName}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<p style="font-weight: bold; font-size: 16px;">Veuillez confirmer votre décision :</p>
|
||||
<div style="margin: 15px 0;">
|
||||
<a href="${acceptUrl}" style="display: inline-block; padding: 15px 30px; background-color: #00aa00; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✓ Accepter la demande</a>
|
||||
<a href="${rejectUrl}" style="display: inline-block; padding: 15px 30px; background-color: #cc0000; color: #ffffff; text-decoration: none; border-radius: 6px; margin: 0 5px; min-width: 200px;">✗ Refuser la demande</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff8e1; border-left: 4px solid #f57c00; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
<strong style="color: #f57c00;">⚠️ Important</strong><br>
|
||||
Cette demande expire automatiquement dans <strong>7 jours</strong> si aucune action n'est prise.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background-color: #f4f6f8; padding: 20px; text-align: center; font-size: 12px; color: #666;">
|
||||
<p style="margin: 5px 0; font-weight: bold; color: #045a8d;">Référence de réservation : ${bookingData.bookingId}</p>
|
||||
<p style="margin: 5px 0;">© 2025 Xpeditis. Tous droits réservés.</p>
|
||||
<p style="margin: 5px 0;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre directement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
try {
|
||||
console.log('Données du booking:');
|
||||
console.log(' Booking ID:', bookingData.bookingId);
|
||||
console.log(' Route:', bookingData.origin, '→', bookingData.destination);
|
||||
console.log(' Prix:', bookingData.priceUSD, 'USD');
|
||||
console.log(' Accept URL:', acceptUrl);
|
||||
console.log(' Reject URL:', rejectUrl);
|
||||
console.log('\nEnvoi en cours...');
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || 'noreply@xpeditis.com',
|
||||
to: 'carrier@test.com',
|
||||
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
||||
html: htmlTemplate,
|
||||
});
|
||||
|
||||
console.log('\n✅ Email transporteur envoyé avec succès!');
|
||||
console.log(' Message ID:', info.messageId);
|
||||
console.log(' Response:', info.response);
|
||||
console.log(' Accepted:', info.accepted);
|
||||
console.log(' Rejected:', info.rejected);
|
||||
console.log('\n📬 Vérifiez votre inbox Mailtrap:');
|
||||
console.log(' URL: https://mailtrap.io/inboxes');
|
||||
console.log(' Sujet: Nouvelle demande de réservation - FRPAR → USNYC');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('\n❌ Échec d\'envoi email transporteur:');
|
||||
console.error(' Message:', error.message);
|
||||
console.error(' Code:', error.code);
|
||||
console.error(' ResponseCode:', error.responseCode);
|
||||
console.error(' Response:', error.response);
|
||||
if (error.stack) {
|
||||
console.error(' Stack:', error.stack.substring(0, 300));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exécuter tous les tests
|
||||
async function runAllTests() {
|
||||
console.log('\n🚀 DÉMARRAGE DES TESTS');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Test 1: Connexion
|
||||
const connectionOk = await testConnection();
|
||||
if (!connectionOk) {
|
||||
console.log('\n❌ ARRÊT: La connexion SMTP a échoué');
|
||||
console.log(' Vérifiez vos credentials SMTP dans .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Test 2: Email simple
|
||||
const simpleEmailOk = await sendSimpleEmail();
|
||||
if (!simpleEmailOk) {
|
||||
console.log('\n⚠️ L\'email simple a échoué, mais on continue...');
|
||||
}
|
||||
|
||||
// Test 3: Email transporteur
|
||||
const carrierEmailOk = await sendCarrierEmail();
|
||||
|
||||
// Résumé
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 RÉSUMÉ DES TESTS:');
|
||||
console.log('='.repeat(60));
|
||||
console.log('Connexion SMTP:', connectionOk ? '✅ OK' : '❌ ÉCHEC');
|
||||
console.log('Email simple:', simpleEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
||||
console.log('Email transporteur:', carrierEmailOk ? '✅ OK' : '❌ ÉCHEC');
|
||||
|
||||
if (connectionOk && simpleEmailOk && carrierEmailOk) {
|
||||
console.log('\n✅ TOUS LES TESTS ONT RÉUSSI!');
|
||||
console.log(' Le système d\'envoi d\'email fonctionne correctement.');
|
||||
console.log(' Si vous ne recevez pas les emails dans le backend,');
|
||||
console.log(' le problème vient de l\'intégration NestJS.');
|
||||
} else {
|
||||
console.log('\n❌ CERTAINS TESTS ONT ÉCHOUÉ');
|
||||
console.log(' Vérifiez les erreurs ci-dessus pour comprendre le problème.');
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
}
|
||||
|
||||
// Lancer les tests
|
||||
runAllTests()
|
||||
.then(() => {
|
||||
console.log('\n✅ Tests terminés\n');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('\n❌ Erreur fatale:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,19 +0,0 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
container_name: xpeditis-postgres
|
||||
environment:
|
||||
POSTGRES_USER: xpeditis
|
||||
POSTGRES_PASSWORD: xpeditis_dev_password
|
||||
POSTGRES_DB: xpeditis_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
container_name: xpeditis-redis
|
||||
command: redis-server --requirepass xpeditis_redis_password
|
||||
environment:
|
||||
REDIS_PASSWORD: xpeditis_redis_password
|
||||
ports:
|
||||
- "6379:6379"
|
||||
@ -100,7 +100,7 @@ deleteTestDocuments()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -14,7 +14,7 @@ function fixImportsInFile(filePath) {
|
||||
|
||||
// Replace relative imports to ../ports/ with @domain/ports/
|
||||
modified = modified.replace(/from ['"]\.\.\/ports\//g, "from '@domain/ports/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, "import $1@domain/ports/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/ports\//g, 'import $1@domain/ports/');
|
||||
|
||||
if (modified !== content) {
|
||||
fs.writeFileSync(filePath, modified, 'utf8');
|
||||
@ -37,7 +37,7 @@ async function fixDummyUrls() {
|
||||
const documents = row.documents;
|
||||
|
||||
// Update each document URL
|
||||
const updatedDocuments = documents.map((doc) => {
|
||||
const updatedDocuments = documents.map(doc => {
|
||||
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
|
||||
// Extract filename from dummy URL
|
||||
const fileName = doc.fileName || doc.filePath.split('/').pop();
|
||||
@ -58,10 +58,10 @@ async function fixDummyUrls() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await client.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(updatedDocuments), bookingId]
|
||||
);
|
||||
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(updatedDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
|
||||
updatedCount++;
|
||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||
@ -84,7 +84,7 @@ fixDummyUrls()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -24,10 +24,13 @@ function fixImportsInFile(filePath) {
|
||||
modified = modified.replace(/from ['"]\.\.\/domain\//g, "from '@domain/");
|
||||
|
||||
// Also fix import statements (not just from)
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, "import $1@domain/");
|
||||
modified = modified.replace(
|
||||
/import\s+(['"])\.\.\/\.\.\/\.\.\/\.\.\/domain\//g,
|
||||
'import $1@domain/'
|
||||
);
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/\.\.\/domain\//g, 'import $1@domain/');
|
||||
modified = modified.replace(/import\s+(['"])\.\.\/domain\//g, 'import $1@domain/');
|
||||
|
||||
if (modified !== content) {
|
||||
fs.writeFileSync(filePath, modified, 'utf8');
|
||||
@ -34,7 +34,7 @@ async function fixMinioHostname() {
|
||||
const documents = row.documents;
|
||||
|
||||
// Update each document URL
|
||||
const updatedDocuments = documents.map((doc) => {
|
||||
const updatedDocuments = documents.map(doc => {
|
||||
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
|
||||
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
|
||||
|
||||
@ -51,10 +51,10 @@ async function fixMinioHostname() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await client.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(updatedDocuments), bookingId]
|
||||
);
|
||||
await client.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(updatedDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
|
||||
updatedCount++;
|
||||
console.log(`✅ Updated booking ${bookingId}\n`);
|
||||
@ -75,7 +75,7 @@ fixMinioHostname()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -120,11 +120,17 @@ async function restoreDocumentReferences() {
|
||||
|
||||
// Determine document type
|
||||
let docType = 'OTHER';
|
||||
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
|
||||
if (
|
||||
file.fileName.toLowerCase().includes('bill-of-lading') ||
|
||||
file.fileName.toLowerCase().includes('bol')
|
||||
) {
|
||||
docType = 'BILL_OF_LADING';
|
||||
} else if (file.fileName.toLowerCase().includes('packing-list')) {
|
||||
docType = 'PACKING_LIST';
|
||||
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
|
||||
} else if (
|
||||
file.fileName.toLowerCase().includes('commercial-invoice') ||
|
||||
file.fileName.toLowerCase().includes('invoice')
|
||||
) {
|
||||
docType = 'COMMERCIAL_INVOICE';
|
||||
}
|
||||
|
||||
@ -143,10 +149,10 @@ async function restoreDocumentReferences() {
|
||||
});
|
||||
|
||||
// Update the booking with new document references
|
||||
await pgClient.query(
|
||||
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
|
||||
[JSON.stringify(newDocuments), bookingId]
|
||||
);
|
||||
await pgClient.query('UPDATE csv_bookings SET documents = $1 WHERE id = $2', [
|
||||
JSON.stringify(newDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
|
||||
updatedCount++;
|
||||
createdDocsCount += newDocuments.length;
|
||||
@ -170,7 +176,7 @@ restoreDocumentReferences()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -114,10 +114,10 @@ async function syncDatabase() {
|
||||
});
|
||||
|
||||
// Update the database
|
||||
await pgClient.query(
|
||||
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
|
||||
[JSON.stringify(validDocuments), bookingId]
|
||||
);
|
||||
await pgClient.query(`UPDATE csv_bookings SET documents = $1 WHERE id = $2`, [
|
||||
JSON.stringify(validDocuments),
|
||||
bookingId,
|
||||
]);
|
||||
|
||||
updatedCount++;
|
||||
removedDocsCount += missingDocuments.length;
|
||||
@ -148,7 +148,7 @@ syncDatabase()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -210,10 +210,7 @@ function parseSeaPorts(filePath: string): ParsedPort[] {
|
||||
|
||||
// Validate coordinates
|
||||
const [longitude, latitude] = port.coordinates;
|
||||
if (
|
||||
latitude < -90 || latitude > 90 ||
|
||||
longitude < -180 || longitude > 180
|
||||
) {
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@ -244,7 +241,8 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
||||
|
||||
for (let i = 0; i < ports.length; i += batchSize) {
|
||||
const batch = ports.slice(i, i + batchSize);
|
||||
const values = batch.map(port => {
|
||||
const values = batch
|
||||
.map(port => {
|
||||
const name = port.name.replace(/'/g, "''");
|
||||
const city = port.city.replace(/'/g, "''");
|
||||
const countryName = port.countryName.replace(/'/g, "''");
|
||||
@ -261,7 +259,8 @@ function generateSQLInserts(ports: ParsedPort[]): string {
|
||||
${timezone},
|
||||
${port.isActive}
|
||||
)`;
|
||||
}).join(',\n ');
|
||||
})
|
||||
.join(',\n ');
|
||||
|
||||
batches.push(`
|
||||
// Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(ports.length / batchSize)} (${batch.length} ports)
|
||||
@ -321,7 +320,9 @@ async function main() {
|
||||
if (!fs.existsSync(seaPortsPath)) {
|
||||
console.error('❌ Error: /tmp/sea-ports.json not found!');
|
||||
console.log('Please download it first:');
|
||||
console.log('curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json');
|
||||
console.log(
|
||||
'curl -o /tmp/sea-ports.json https://raw.githubusercontent.com/marchah/sea-ports/master/lib/ports.json'
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -342,7 +343,10 @@ async function main() {
|
||||
const migrationContent = generateMigration(ports);
|
||||
|
||||
// Write migration file
|
||||
const migrationsDir = path.join(__dirname, '../src/infrastructure/persistence/typeorm/migrations');
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
'../src/infrastructure/persistence/typeorm/migrations'
|
||||
);
|
||||
const timestamp = Date.now();
|
||||
const fileName = `${timestamp}-SeedPorts.ts`;
|
||||
const filePath = path.join(migrationsDir, fileName);
|
||||
@ -86,7 +86,7 @@ listFiles()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -5,7 +5,10 @@
|
||||
|
||||
const Stripe = require('stripe');
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr');
|
||||
const stripe = new Stripe(
|
||||
process.env.STRIPE_SECRET_KEY ||
|
||||
'sk_test_51R8p8R4atifoBlu1U9sMJh3rkQbO1G1xeguwFMQYMIMeaLNrTX7YFO5Ovu3P1VfbwcOoEmiy6I0UWi4DThNNzHG100YF75TnJr'
|
||||
);
|
||||
|
||||
async function listPrices() {
|
||||
console.log('Fetching Stripe prices...\n');
|
||||
@ -46,7 +49,6 @@ async function listPrices() {
|
||||
console.log('STRIPE_PRO_YEARLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_MONTHLY_PRICE_ID=price_xxxxx');
|
||||
console.log('STRIPE_ENTERPRISE_YEARLY_PRICE_ID=price_xxxxx');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching prices:', error.message);
|
||||
}
|
||||
@ -28,7 +28,7 @@ AppDataSource.initialize()
|
||||
console.log('✅ No pending migrations');
|
||||
} else {
|
||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||
migrations.forEach((migration) => {
|
||||
migrations.forEach(migration => {
|
||||
console.log(` - ${migration.name}`);
|
||||
});
|
||||
}
|
||||
@ -37,7 +37,7 @@ AppDataSource.initialize()
|
||||
console.log('✅ Database migrations completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('❌ Error during migration:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
@ -73,7 +73,7 @@ setBucketPolicy()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -58,7 +58,7 @@ async function runMigrations() {
|
||||
console.log('✅ No pending migrations');
|
||||
} else {
|
||||
console.log(`✅ Successfully ran ${migrations.length} migration(s):`);
|
||||
migrations.forEach((migration) => {
|
||||
migrations.forEach(migration => {
|
||||
console.log(` - ${migration.name}`);
|
||||
});
|
||||
}
|
||||
@ -77,10 +77,10 @@ function startApplication() {
|
||||
|
||||
const app = spawn('node', ['dist/main'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
app.on('exit', (code) => {
|
||||
app.on('exit', code => {
|
||||
process.exit(code);
|
||||
});
|
||||
|
||||
@ -96,7 +96,7 @@ async function main() {
|
||||
startApplication();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
main().catch(error => {
|
||||
console.error('❌ Startup failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -179,7 +179,7 @@ uploadTestDocuments()
|
||||
console.log('\n✅ Script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ Script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -78,7 +78,7 @@ async function createTestBooking() {
|
||||
25.5, // volume_cbm
|
||||
3500, // weight_kg
|
||||
10, // pallet_count
|
||||
1850.50, // price_usd
|
||||
1850.5, // price_usd
|
||||
1665.45, // price_eur
|
||||
'USD', // primary_currency
|
||||
28, // transit_days
|
||||
@ -102,7 +102,6 @@ async function createTestBooking() {
|
||||
console.log('\n📧 URL API (pour curl):');
|
||||
console.log(` curl http://localhost:4000/api/v1/csv-bookings/accept/${confirmationToken}`);
|
||||
console.log('\n✅ Ce booking est en statut PENDING et peut être accepté/refusé.\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur:', error.message);
|
||||
console.error(error);
|
||||
@ -9,18 +9,21 @@ async function loginAndTestEmail() {
|
||||
console.log('🔐 Connexion...');
|
||||
const loginResponse = await axios.post(`${API_URL}/auth/login`, {
|
||||
email: 'admin@xpeditis.com',
|
||||
password: 'Admin123!@#'
|
||||
password: 'Admin123!@#',
|
||||
});
|
||||
|
||||
const token = loginResponse.data.accessToken;
|
||||
console.log('✅ Connecté avec succès\n');
|
||||
|
||||
// 2. Créer un CSV booking pour tester l'envoi d'email
|
||||
console.log('📧 Création d\'une CSV booking pour tester l\'envoi d\'email...');
|
||||
console.log("📧 Création d'une CSV booking pour tester l'envoi d'email...");
|
||||
|
||||
const form = new FormData();
|
||||
const testFile = Buffer.from('Test document PDF content');
|
||||
form.append('documents', testFile, { filename: 'test-doc.pdf', contentType: 'application/pdf' });
|
||||
form.append('documents', testFile, {
|
||||
filename: 'test-doc.pdf',
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
form.append('carrierName', 'Test Carrier');
|
||||
form.append('carrierEmail', 'testcarrier@example.com');
|
||||
@ -39,8 +42,8 @@ async function loginAndTestEmail() {
|
||||
const bookingResponse = await axios.post(`${API_URL}/csv-bookings`, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ CSV Booking créé:', bookingResponse.data.id);
|
||||
@ -50,7 +53,6 @@ async function loginAndTestEmail() {
|
||||
console.log('2. Vérifier Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||
console.log('3. Email devrait être envoyé à: testcarrier@example.com');
|
||||
console.log('\n⏳ Attendez quelques secondes puis vérifiez les logs du backend...');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERREUR:');
|
||||
if (error.response) {
|
||||
@ -56,16 +56,12 @@ async function testWorkflow() {
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
const bookingResponse = await axios.post(
|
||||
`${API_BASE}/csv-bookings`,
|
||||
form,
|
||||
{
|
||||
const bookingResponse = await axios.post(`${API_BASE}/csv-bookings`, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log('✅ Booking created successfully!');
|
||||
console.log('📦 Booking ID:', bookingResponse.data.id);
|
||||
@ -80,7 +76,9 @@ async function testWorkflow() {
|
||||
console.error('❌ Error:', error.response?.data || error.message);
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
console.error('\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.');
|
||||
console.error(
|
||||
'\n⚠️ Authentication failed. Please update TEST_USER credentials in the script.'
|
||||
);
|
||||
}
|
||||
|
||||
if (error.response?.status === 400) {
|
||||
@ -213,7 +213,9 @@ async function testEmailConfig() {
|
||||
console.log('📊 Résumé des tests:');
|
||||
console.log(' ✓ Vérifiez Mailtrap inbox: https://mailtrap.io/inboxes');
|
||||
console.log(' ✓ Recherchez les emails de test ci-dessus');
|
||||
console.log(' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n');
|
||||
console.log(
|
||||
' ✓ Si Test 2 et 3 réussissent, le backend doit être corrigé avec la configuration IP directe\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Run test
|
||||
@ -222,7 +224,7 @@ testEmailConfig()
|
||||
console.log('✅ Tests terminés avec succès');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('❌ Erreur lors des tests:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
32
apps/backend/scripts/test/test-carrier-email.js
Normal file
32
apps/backend/scripts/test/test-carrier-email.js
Normal file
@ -0,0 +1,32 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'sandbox.smtp.mailtrap.io',
|
||||
port: 2525,
|
||||
auth: {
|
||||
user: '2597bd31d265eb',
|
||||
pass: 'cd126234193c89',
|
||||
},
|
||||
});
|
||||
|
||||
console.log("🔄 Tentative d'envoi d'email...");
|
||||
|
||||
transporter
|
||||
.sendMail({
|
||||
from: 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Email depuis Portail Transporteur',
|
||||
text: 'Email de test pour vérifier la configuration',
|
||||
})
|
||||
.then(info => {
|
||||
console.log('✅ Email envoyé:', info.messageId);
|
||||
console.log('📧 Response:', info.response);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ Erreur:', err.message);
|
||||
console.error('Code:', err.code);
|
||||
console.error('Command:', err.command);
|
||||
console.error('Stack:', err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
|
||||
|
||||
console.log('\n1️⃣ Verifying SMTP connection...');
|
||||
|
||||
transporter.verify()
|
||||
transporter
|
||||
.verify()
|
||||
.then(() => {
|
||||
console.log('✅ SMTP connection verified!');
|
||||
console.log('\n2️⃣ Sending test email...');
|
||||
@ -40,17 +41,17 @@ transporter.verify()
|
||||
from: 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Xpeditis - Envoi Direct IP',
|
||||
html: '<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l\'IP directe pour contourner le DNS.</p>',
|
||||
html: "<h1>✅ Email envoyé avec succès!</h1><p>Ce test utilise l'IP directe pour contourner le DNS.</p>",
|
||||
});
|
||||
})
|
||||
.then((info) => {
|
||||
.then(info => {
|
||||
console.log('✅ Email sent successfully!');
|
||||
console.log('📧 Message ID:', info.messageId);
|
||||
console.log('📬 Response:', info.response);
|
||||
console.log('\n🎉 SUCCESS! Email sending works with IP directly.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('\n❌ ERROR:', error.message);
|
||||
console.error('Code:', error.code);
|
||||
console.error('Command:', error.command);
|
||||
@ -6,7 +6,8 @@ const axios = require('axios');
|
||||
const API_URL = 'http://localhost:4000/api/v1';
|
||||
|
||||
// Token d'authentification (admin@xpeditis.com)
|
||||
const AUTH_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I';
|
||||
const AUTH_TOKEN =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MTI3Y2M0Zi04Yzg4LTRjNGUtYmU1ZC1hNmY1ZTE2MWZlNDMiLCJlbWFpbCI6ImFkbWluQHhwZWRpdGlzLmNvbSIsInJvbGUiOiJBRE1JTiIsIm9yZ2FuaXphdGlvbklkIjoiMWZhOWE1NjUtZjNjOC00ZTExLTliMzAtMTIwZDEwNTJjZWYwIiwidHlwZSI6ImFjY2VzcyIsImlhdCI6MTc2NDg3NDQ2MSwiZXhwIjoxNzY0ODc1MzYxfQ.l_-97_rikGj-DP8aA14CK-Ab-0Usy722MRe1lqi0u9I';
|
||||
|
||||
async function testCsvBookingEmail() {
|
||||
console.log('🧪 Test envoi email via CSV booking...\n');
|
||||
@ -19,7 +20,10 @@ async function testCsvBookingEmail() {
|
||||
|
||||
// Créer un fichier de test temporaire
|
||||
const testFile = Buffer.from('Test document content');
|
||||
form.append('documents', testFile, { filename: 'test-document.pdf', contentType: 'application/pdf' });
|
||||
form.append('documents', testFile, {
|
||||
filename: 'test-document.pdf',
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
// Ajouter les champs du formulaire
|
||||
form.append('carrierName', 'Test Carrier Email');
|
||||
@ -41,8 +45,8 @@ async function testCsvBookingEmail() {
|
||||
const response = await axios.post(`${API_URL}/csv-bookings`, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
Authorization: `Bearer ${AUTH_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('✅ Réponse reçue:', response.status);
|
||||
@ -51,11 +55,10 @@ async function testCsvBookingEmail() {
|
||||
console.log('1. Les logs du backend pour voir "Email sent to carrier:"');
|
||||
console.log('2. Votre inbox Mailtrap: https://mailtrap.io/inboxes');
|
||||
console.log('3. Email destinataire: test-carrier@example.com');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur:', error.response?.data || error.message);
|
||||
if (error.response?.status === 401) {
|
||||
console.error('\n⚠️ Token expiré. Connectez-vous d\'abord avec:');
|
||||
console.error("\n⚠️ Token expiré. Connectez-vous d'abord avec:");
|
||||
console.error('POST /api/v1/auth/login');
|
||||
console.error('{ "email": "admin@xpeditis.com", "password": "..." }');
|
||||
}
|
||||
@ -31,7 +31,8 @@ const transporter = nodemailer.createTransport(config);
|
||||
|
||||
console.log('\nVerifying SMTP connection...');
|
||||
|
||||
transporter.verify()
|
||||
transporter
|
||||
.verify()
|
||||
.then(() => {
|
||||
console.log('✅ SMTP connection verified successfully!');
|
||||
console.log('\nSending test email...');
|
||||
@ -43,13 +44,13 @@ transporter.verify()
|
||||
html: '<h1>Test Email</h1><p>If you see this, email sending works!</p>',
|
||||
});
|
||||
})
|
||||
.then((info) => {
|
||||
.then(info => {
|
||||
console.log('✅ Email sent successfully!');
|
||||
console.log('Message ID:', info.messageId);
|
||||
console.log('Response:', info.response);
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(error => {
|
||||
console.error('❌ Error:', error.message);
|
||||
console.error('Full error:', error);
|
||||
process.exit(1);
|
||||
@ -49,12 +49,12 @@ async function test() {
|
||||
await transporter.verify();
|
||||
console.log('✅ Connexion SMTP OK\n');
|
||||
|
||||
console.log('Test 2: Envoi d\'un email...');
|
||||
console.log("Test 2: Envoi d'un email...");
|
||||
const info = await transporter.sendMail({
|
||||
from: 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test - ' + new Date().toISOString(),
|
||||
html: '<h1>Test réussi!</h1><p>Ce message confirme que l\'envoi d\'email fonctionne.</p>',
|
||||
html: "<h1>Test réussi!</h1><p>Ce message confirme que l'envoi d'email fonctionne.</p>",
|
||||
});
|
||||
|
||||
console.log('✅ Email envoyé avec succès!');
|
||||
@ -26,8 +26,9 @@ import { AuditModule } from './application/audit/audit.module';
|
||||
import { NotificationsModule } from './application/notifications/notifications.module';
|
||||
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||
import { CsvBookingsModule } from './application/csv-bookings/csv-bookings.module';
|
||||
import { AdminModule } from './application/admin/admin.module';
|
||||
import { BlogModule } from './application/blog/blog.module';
|
||||
import { LogsModule } from './application/logs/logs.module';
|
||||
import { SubscriptionsModule } from './application/subscriptions/subscriptions.module';
|
||||
import { ApiKeysModule } from './application/api-keys/api-keys.module';
|
||||
@ -179,6 +180,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
WebhooksModule,
|
||||
GDPRModule,
|
||||
AdminModule,
|
||||
BlogModule,
|
||||
SubscriptionsModule,
|
||||
ApiKeysModule,
|
||||
LogsModule,
|
||||
|
||||
@ -24,23 +24,25 @@ import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.po
|
||||
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
|
||||
|
||||
// CSV Booking Service
|
||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
import { CsvBookingsModule } from '../csv-bookings/csv-bookings.module';
|
||||
|
||||
// Email
|
||||
import { EmailModule } from '@infrastructure/email/email.module';
|
||||
|
||||
/**
|
||||
* Admin Module
|
||||
*
|
||||
* Provides admin-only endpoints for managing all data in the system.
|
||||
* All endpoints require ADMIN role.
|
||||
*/
|
||||
// Blog
|
||||
import { BlogModule } from '../blog/blog.module';
|
||||
|
||||
// Storage
|
||||
import { StorageModule } from '@infrastructure/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
||||
ConfigModule,
|
||||
CsvBookingsModule,
|
||||
EmailModule,
|
||||
BlogModule,
|
||||
StorageModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
|
||||
22
apps/backend/src/application/blog/blog.module.ts
Normal file
22
apps/backend/src/application/blog/blog.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BlogController } from '../controllers/blog.controller';
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { BlogPostOrmEntity } from '../../infrastructure/persistence/typeorm/entities/blog-post.orm-entity';
|
||||
import { TypeOrmBlogPostRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository';
|
||||
import { BLOG_POST_REPOSITORY } from '@domain/ports/out/blog-post.repository';
|
||||
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BlogPostOrmEntity]), StorageModule],
|
||||
controllers: [BlogController],
|
||||
providers: [
|
||||
BlogService,
|
||||
{
|
||||
provide: BLOG_POST_REPOSITORY,
|
||||
useClass: TypeOrmBlogPostRepository,
|
||||
},
|
||||
],
|
||||
exports: [BlogService],
|
||||
})
|
||||
export class BlogModule {}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
@ -15,14 +16,22 @@ import {
|
||||
BadRequestException,
|
||||
ParseUUIDPipe,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiConsumes,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
@ -56,6 +65,25 @@ import {
|
||||
// Email imports
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
|
||||
// Blog imports
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
|
||||
import { BlogPost } from '@domain/entities/blog-post.entity';
|
||||
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
|
||||
// Storage imports
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
const ALLOWED_IMAGE_MIMETYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/svg+xml',
|
||||
];
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
/**
|
||||
* Admin Controller
|
||||
*
|
||||
@ -80,7 +108,9 @@ export class AdminController {
|
||||
private readonly csvBookingService: CsvBookingService,
|
||||
@Inject(SIRET_VERIFICATION_PORT)
|
||||
private readonly siretVerificationPort: SiretVerificationPort,
|
||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort
|
||||
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort,
|
||||
private readonly blogService: BlogService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort
|
||||
) {}
|
||||
|
||||
// ==================== USERS ENDPOINTS ====================
|
||||
@ -912,4 +942,138 @@ export class AdminController {
|
||||
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
||||
return { success: true, message: 'Document deleted successfully' };
|
||||
}
|
||||
|
||||
// ==================== BLOG ENDPOINTS ====================
|
||||
|
||||
@Post('blog/images')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('image', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: MAX_IMAGE_SIZE },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (ALLOWED_IMAGE_MIMETYPES.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException('Only image files are allowed (jpg, png, webp, gif, svg)'),
|
||||
false
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload a blog image to storage (Admin only)' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
schema: { properties: { url: { type: 'string' }, filename: { type: 'string' } } },
|
||||
})
|
||||
async uploadBlogImage(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ url: string; filename: string }> {
|
||||
if (!file) throw new BadRequestException('No image file provided');
|
||||
|
||||
this.logger.log(`[ADMIN: ${user.email}] Uploading blog image: ${file.originalname}`);
|
||||
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const sanitizedName = path
|
||||
.basename(file.originalname, ext)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${uuidv4()}-${sanitizedName}${ext}`;
|
||||
const key = `blog-images/${filename}`;
|
||||
|
||||
await this.storage.upload({
|
||||
bucket: BLOG_IMAGES_BUCKET,
|
||||
key,
|
||||
body: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
|
||||
this.logger.log(`[ADMIN] Blog image uploaded: ${key}`);
|
||||
return { url: `/api/v1/blog/images/${filename}`, filename };
|
||||
}
|
||||
|
||||
@Get('blog')
|
||||
@ApiOperation({ summary: 'List all blog posts (Admin only)' })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'category', required: false })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
async listBlogPosts(
|
||||
@Query('status') status?: any,
|
||||
@Query('category') category?: BlogPostCategory,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit = 50,
|
||||
@Query('offset') offset = 0,
|
||||
@CurrentUser() user?: UserPayload
|
||||
) {
|
||||
this.logger.log(`[ADMIN: ${user?.email}] Listing blog posts`);
|
||||
const { posts, total } = await this.blogService.listAllPosts({
|
||||
status,
|
||||
category,
|
||||
search,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
});
|
||||
return { posts: posts.map(this.mapBlogPostToDto), total };
|
||||
}
|
||||
|
||||
@Post('blog')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@ApiOperation({ summary: 'Create a blog post (Admin only)' })
|
||||
async createBlogPost(@Body() dto: CreateBlogPostDto, @CurrentUser() user: UserPayload) {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Creating blog post: ${dto.slug}`);
|
||||
const post = await this.blogService.createPost(dto);
|
||||
return this.mapBlogPostToDto(post);
|
||||
}
|
||||
|
||||
@Patch('blog/:id')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
@ApiOperation({ summary: 'Update a blog post (Admin only)' })
|
||||
async updateBlogPost(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBlogPostDto,
|
||||
@CurrentUser() user: UserPayload
|
||||
) {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Updating blog post: ${id}`);
|
||||
const post = await this.blogService.updatePost(id, dto);
|
||||
return this.mapBlogPostToDto(post);
|
||||
}
|
||||
|
||||
@Delete('blog/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Delete a blog post (Admin only)' })
|
||||
async deleteBlogPost(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<void> {
|
||||
this.logger.log(`[ADMIN: ${user.email}] Deleting blog post: ${id}`);
|
||||
await this.blogService.deletePost(id);
|
||||
}
|
||||
|
||||
private mapBlogPostToDto(post: BlogPost) {
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
coverImageUrl: post.coverImageUrl,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
status: post.status,
|
||||
isFeatured: post.isFeatured,
|
||||
publishedAt: post.publishedAt,
|
||||
metaTitle: post.metaTitle,
|
||||
metaDescription: post.metaDescription,
|
||||
primaryKeyword: post.primaryKeyword,
|
||||
secondaryKeywords: post.secondaryKeywords,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
135
apps/backend/src/application/controllers/blog.controller.ts
Normal file
135
apps/backend/src/application/controllers/blog.controller.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Res,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
Logger,
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { BlogService } from '../services/blog.service';
|
||||
import { BlogPost } from '@domain/entities/blog-post.entity';
|
||||
import { BlogPostResponseDto, BlogPostListResponseDto } from '../dto/blog-post.dto';
|
||||
import type { BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
|
||||
@ApiTags('Blog')
|
||||
@Controller('blog')
|
||||
@Public()
|
||||
export class BlogController {
|
||||
private readonly logger = new Logger(BlogController.name);
|
||||
|
||||
constructor(
|
||||
private readonly blogService: BlogService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'List published blog posts' })
|
||||
@ApiQuery({
|
||||
name: 'category',
|
||||
required: false,
|
||||
enum: ['industry', 'technology', 'guides', 'news'],
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'offset', required: false, type: Number })
|
||||
@ApiResponse({ status: 200, type: BlogPostListResponseDto })
|
||||
async listPosts(
|
||||
@Query('category') category?: BlogPostCategory,
|
||||
@Query('search') search?: string,
|
||||
@Query('limit') limit = 20,
|
||||
@Query('offset') offset = 0
|
||||
): Promise<BlogPostListResponseDto> {
|
||||
const { posts, total } = await this.blogService.listPublishedPosts({
|
||||
category,
|
||||
search,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
});
|
||||
|
||||
return {
|
||||
posts: posts.map(this.mapToDto),
|
||||
total,
|
||||
limit: Number(limit),
|
||||
offset: Number(offset),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('images/:filename')
|
||||
@ApiOperation({ summary: 'Serve a blog image from storage' })
|
||||
@ApiParam({ name: 'filename' })
|
||||
async serveImage(
|
||||
@Param('filename') filename: string,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
): Promise<StreamableFile> {
|
||||
const key = `blog-images/${filename}`;
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await this.storage.download({ bucket: BLOG_IMAGES_BUCKET, key });
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to serve blog image "${key}": ${err?.message}`);
|
||||
throw new NotFoundException(`Image not found: ${filename}`);
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
};
|
||||
const contentType = contentTypeMap[ext] ?? 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
return new StreamableFile(buffer);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Get a published blog post by slug' })
|
||||
@ApiParam({ name: 'slug' })
|
||||
@ApiResponse({ status: 200, type: BlogPostResponseDto })
|
||||
async getPost(@Param('slug') slug: string): Promise<BlogPostResponseDto> {
|
||||
const post = await this.blogService.getPublishedPostBySlug(slug);
|
||||
return this.mapToDto(post);
|
||||
}
|
||||
|
||||
private mapToDto(post: BlogPost): BlogPostResponseDto {
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: post.content,
|
||||
coverImageUrl: post.coverImageUrl,
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
status: post.status,
|
||||
isFeatured: post.isFeatured,
|
||||
publishedAt: post.publishedAt,
|
||||
metaTitle: post.metaTitle,
|
||||
metaDescription: post.metaDescription,
|
||||
primaryKeyword: post.primaryKeyword,
|
||||
secondaryKeywords: post.secondaryKeywords,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,16 @@
|
||||
export * from './rates.controller';
|
||||
export * from './bookings.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './users.controller';
|
||||
export * from './organizations.controller';
|
||||
export * from './ports.controller';
|
||||
export * from './notifications.controller';
|
||||
export * from './webhooks.controller';
|
||||
export * from './audit.controller';
|
||||
export * from './subscriptions.controller';
|
||||
export * from './invitations.controller';
|
||||
export * from './gdpr.controller';
|
||||
export * from './health.controller';
|
||||
export * from './blog.controller';
|
||||
export * from './csv-bookings.controller';
|
||||
export * from './csv-booking-actions.controller';
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ParseUUIDPipe,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
@ -41,10 +42,16 @@ import {
|
||||
ORGANIZATION_REPOSITORY,
|
||||
} from '@domain/ports/out/organization.repository';
|
||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||
import {
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
} from '@domain/entities/notification.entity';
|
||||
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../guards/roles.guard';
|
||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||
import { Roles } from '../decorators/roles.decorator';
|
||||
import { NotificationService } from '../services/notification.service';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
@ -64,7 +71,9 @@ export class OrganizationsController {
|
||||
private readonly logger = new Logger(OrganizationsController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||
private readonly notificationService: NotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -123,6 +132,11 @@ export class OrganizationsController {
|
||||
name: dto.name,
|
||||
type: dto.type,
|
||||
scac: dto.scac,
|
||||
siren: dto.siren,
|
||||
siret: dto.siret,
|
||||
eori: dto.eori,
|
||||
contact_phone: dto.contact_phone,
|
||||
contact_email: dto.contact_email,
|
||||
address: OrganizationMapper.mapDtoToAddress(dto.address),
|
||||
logoUrl: dto.logoUrl,
|
||||
documents: [],
|
||||
@ -252,6 +266,10 @@ export class OrganizationsController {
|
||||
organization.updateSiren(dto.siren);
|
||||
}
|
||||
|
||||
if (dto.siret) {
|
||||
organization.updateSiret(dto.siret);
|
||||
}
|
||||
|
||||
if (dto.eori) {
|
||||
organization.updateEori(dto.eori);
|
||||
}
|
||||
@ -288,6 +306,72 @@ export class OrganizationsController {
|
||||
return OrganizationMapper.toDto(updatedOrg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request SIRET/SIREN approval from admins
|
||||
*
|
||||
* Any authenticated user can call this to notify all admins
|
||||
* that their organization's SIRET/SIREN needs approval.
|
||||
*/
|
||||
@Post('request-siret-approval')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Request SIRET/SIREN approval',
|
||||
description: 'Sends a notification to all admins requesting manual SIRET/SIREN verification.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
||||
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
||||
async requestSiretApproval(
|
||||
@CurrentUser() user: UserPayload
|
||||
): Promise<{ message: string }> {
|
||||
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||
if (!organization) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
}
|
||||
|
||||
if (!organization.siren && !organization.siret) {
|
||||
throw new BadRequestException(
|
||||
'Aucun SIRET ou SIREN renseigné sur votre organisation. Veuillez les ajouter avant de demander la validation.'
|
||||
);
|
||||
}
|
||||
|
||||
if (organization.siretVerified) {
|
||||
throw new BadRequestException('Votre SIRET/SIREN est déjà vérifié.');
|
||||
}
|
||||
|
||||
const admins = await this.userRepository.findByRole('ADMIN');
|
||||
|
||||
const identifier = organization.siret
|
||||
? `SIRET ${organization.siret}`
|
||||
: `SIREN ${organization.siren}`;
|
||||
|
||||
await Promise.all(
|
||||
admins.map(admin =>
|
||||
this.notificationService.createNotification({
|
||||
userId: admin.id,
|
||||
organizationId: admin.organizationId,
|
||||
type: NotificationType.ORGANIZATION_UPDATE,
|
||||
priority: NotificationPriority.HIGH,
|
||||
title: 'Demande de validation SIRET/SIREN',
|
||||
message: `L'organisation "${organization.name}" demande la validation de son ${identifier}.`,
|
||||
metadata: {
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name,
|
||||
siret: organization.siret,
|
||||
siren: organization.siren,
|
||||
requestedBy: user.email,
|
||||
},
|
||||
actionUrl: `/dashboard/admin/organizations`,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[${user.email}] SIRET/SIREN approval requested for org ${organization.name} (${organization.id})`
|
||||
);
|
||||
|
||||
return { message: 'Votre demande a été envoyée aux administrateurs.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* List organizations
|
||||
*
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
||||
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
|
||||
import { CsvBookingService } from './services/csv-booking.service';
|
||||
import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||
import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { EmailModule } from '../infrastructure/email/email.module';
|
||||
import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||
import { StripeModule } from '../infrastructure/stripe/stripe.module';
|
||||
|
||||
/**
|
||||
* CSV Bookings Module
|
||||
*
|
||||
* Handles CSV-based booking workflow with carrier email confirmations
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||||
ConfigModule,
|
||||
NotificationsModule,
|
||||
EmailModule,
|
||||
StorageModule,
|
||||
SubscriptionsModule,
|
||||
StripeModule,
|
||||
],
|
||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||
providers: [
|
||||
CsvBookingService,
|
||||
TypeOrmCsvBookingRepository,
|
||||
{
|
||||
provide: SHIPMENT_COUNTER_PORT,
|
||||
useClass: TypeOrmShipmentCounterRepository,
|
||||
},
|
||||
{
|
||||
provide: ORGANIZATION_REPOSITORY,
|
||||
useClass: TypeOrmOrganizationRepository,
|
||||
},
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: TypeOrmUserRepository,
|
||||
},
|
||||
],
|
||||
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||
})
|
||||
export class CsvBookingsModule {}
|
||||
@ -0,0 +1,57 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CsvBookingsController } from '../controllers/csv-bookings.controller';
|
||||
import { CsvBookingActionsController } from '../controllers/csv-booking-actions.controller';
|
||||
import { CsvBookingService } from '../services/csv-booking.service';
|
||||
import { CsvBookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||
import { TypeOrmShipmentCounterRepository } from '../../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { TypeOrmUserRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { EmailModule } from '../../infrastructure/email/email.module';
|
||||
import { StorageModule } from '../../infrastructure/storage/storage.module';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
import { StripeModule } from '../../infrastructure/stripe/stripe.module';
|
||||
|
||||
/**
|
||||
* CSV Bookings Module
|
||||
*
|
||||
* Handles CSV-based booking workflow with carrier email confirmations
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||||
ConfigModule,
|
||||
NotificationsModule,
|
||||
EmailModule,
|
||||
StorageModule,
|
||||
SubscriptionsModule,
|
||||
StripeModule,
|
||||
],
|
||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||
providers: [
|
||||
CsvBookingService,
|
||||
TypeOrmCsvBookingRepository,
|
||||
{
|
||||
provide: SHIPMENT_COUNTER_PORT,
|
||||
useClass: TypeOrmShipmentCounterRepository,
|
||||
},
|
||||
{
|
||||
provide: ORGANIZATION_REPOSITORY,
|
||||
useClass: TypeOrmOrganizationRepository,
|
||||
},
|
||||
{
|
||||
provide: USER_REPOSITORY,
|
||||
useClass: TypeOrmUserRepository,
|
||||
},
|
||||
],
|
||||
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||
})
|
||||
export class CsvBookingsModule {}
|
||||
@ -7,7 +7,7 @@ import { DashboardController } from './dashboard.controller';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { RatesModule } from '../rates/rates.module';
|
||||
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||
import { CsvBookingsModule } from '../csv-bookings/csv-bookings.module';
|
||||
import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
|
||||
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './current-user.decorator';
|
||||
export * from './public.decorator';
|
||||
export * from './roles.decorator';
|
||||
export * from './requires-feature.decorator';
|
||||
|
||||
213
apps/backend/src/application/dto/blog-post.dto.ts
Normal file
213
apps/backend/src/application/dto/blog-post.dto.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { BlogPostStatus, type BlogPostCategory } from '@domain/entities/blog-post.entity';
|
||||
|
||||
const CATEGORIES: BlogPostCategory[] = ['industry', 'technology', 'guides', 'news'];
|
||||
|
||||
export class CreateBlogPostDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'URL-friendly slug, e.g. "my-article"' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'Slug must be lowercase alphanumeric with hyphens',
|
||||
})
|
||||
slug: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(10)
|
||||
excerpt: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiProperty({ enum: CATEGORIES })
|
||||
@IsEnum(CATEGORIES)
|
||||
category: BlogPostCategory;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
authorName: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO date string for scheduled publication' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SEO meta title (50-60 chars recommended)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
metaTitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SEO meta description (150-160 chars recommended)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
metaDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Primary SEO keyword' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
primaryKeyword?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Secondary SEO keywords' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
secondaryKeywords?: string[];
|
||||
}
|
||||
|
||||
export class UpdateBlogPostDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'Slug must be lowercase alphanumeric with hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
excerpt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
coverImageUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CATEGORIES })
|
||||
@IsOptional()
|
||||
@IsEnum(CATEGORIES)
|
||||
category?: BlogPostCategory;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
authorName?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BlogPostStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(BlogPostStatus)
|
||||
status?: BlogPostStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFeatured?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'ISO date string for scheduled publication' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
metaTitle?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
metaDescription?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
primaryKeyword?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
secondaryKeywords?: string[];
|
||||
}
|
||||
|
||||
export class BlogPostResponseDto {
|
||||
@ApiProperty() id: string;
|
||||
@ApiProperty() title: string;
|
||||
@ApiProperty() slug: string;
|
||||
@ApiProperty() excerpt: string;
|
||||
@ApiProperty() content: string;
|
||||
@ApiPropertyOptional() coverImageUrl?: string;
|
||||
@ApiProperty() category: string;
|
||||
@ApiProperty({ type: [String] }) tags: string[];
|
||||
@ApiProperty() authorName: string;
|
||||
@ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus;
|
||||
@ApiProperty() isFeatured: boolean;
|
||||
@ApiPropertyOptional() publishedAt?: Date;
|
||||
@ApiPropertyOptional() metaTitle?: string;
|
||||
@ApiPropertyOptional() metaDescription?: string;
|
||||
@ApiPropertyOptional() primaryKeyword?: string;
|
||||
@ApiProperty({ type: [String] }) secondaryKeywords: string[];
|
||||
@ApiProperty() createdAt: Date;
|
||||
@ApiProperty() updatedAt: Date;
|
||||
}
|
||||
|
||||
export class BlogPostListResponseDto {
|
||||
@ApiProperty({ type: [BlogPostResponseDto] }) posts: BlogPostResponseDto[];
|
||||
@ApiProperty() total: number;
|
||||
@ApiProperty() limit: number;
|
||||
@ApiProperty() offset: number;
|
||||
}
|
||||
@ -104,16 +104,21 @@ export class CreateOrganizationDto {
|
||||
@ApiPropertyOptional({
|
||||
example: '123456789',
|
||||
description: 'French SIREN number (9 digits)',
|
||||
minLength: 9,
|
||||
maxLength: 9,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(9)
|
||||
@MaxLength(9)
|
||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||
siren?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '12345678901234',
|
||||
description: 'French SIRET number (14 digits)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
||||
siret?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'FR123456789',
|
||||
description: 'EU EORI number',
|
||||
@ -174,26 +179,18 @@ export class UpdateOrganizationDto {
|
||||
@ApiPropertyOptional({
|
||||
example: '123456789',
|
||||
description: 'French SIREN number (9 digits)',
|
||||
minLength: 9,
|
||||
maxLength: 9,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(9)
|
||||
@MaxLength(9)
|
||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||
siren?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: '12345678901234',
|
||||
description: 'French SIRET number (14 digits)',
|
||||
minLength: 14,
|
||||
maxLength: 14,
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MinLength(14)
|
||||
@MaxLength(14)
|
||||
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
||||
siret?: string;
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export * from './jwt-auth.guard';
|
||||
export * from './roles.guard';
|
||||
export * from './api-key-or-jwt.guard';
|
||||
export * from './feature-flag.guard';
|
||||
export * from './throttle.guard';
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
export * from './rate-quote.mapper';
|
||||
export * from './booking.mapper';
|
||||
export * from './port.mapper';
|
||||
export * from './user.mapper';
|
||||
export * from './organization.mapper';
|
||||
export * from './csv-rate.mapper';
|
||||
|
||||
@ -1,15 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { OrganizationsController } from '../controllers/organizations.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
// Import domain ports
|
||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
|
||||
TypeOrmModule.forFeature([OrganizationOrmEntity]),
|
||||
UsersModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [OrganizationsController],
|
||||
providers: [
|
||||
@ -18,8 +21,6 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/
|
||||
useClass: TypeOrmOrganizationRepository,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ORGANIZATION_REPOSITORY, // optional, if other modules need it
|
||||
],
|
||||
exports: [ORGANIZATION_REPOSITORY],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
@ -45,12 +45,16 @@ import { CarrierOrmEntity } from '../../infrastructure/persistence/typeorm/entit
|
||||
},
|
||||
{
|
||||
provide: RateSearchService,
|
||||
useFactory: (cache: any, rateQuoteRepo: any, portRepo: any, carrierRepo: any) => {
|
||||
// For now, create service with empty connectors array
|
||||
// TODO: Inject actual carrier connectors
|
||||
return new RateSearchService([], cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||
useFactory: (
|
||||
connectors: any[],
|
||||
cache: any,
|
||||
rateQuoteRepo: any,
|
||||
portRepo: any,
|
||||
carrierRepo: any,
|
||||
) => {
|
||||
return new RateSearchService(connectors, cache, rateQuoteRepo, portRepo, carrierRepo);
|
||||
},
|
||||
inject: [CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
||||
inject: ['CarrierConnectors', CACHE_PORT, RATE_QUOTE_REPOSITORY, PORT_REPOSITORY, CARRIER_REPOSITORY],
|
||||
},
|
||||
],
|
||||
exports: [RATE_QUOTE_REPOSITORY, RateSearchService],
|
||||
|
||||
158
apps/backend/src/application/services/blog.service.ts
Normal file
158
apps/backend/src/application/services/blog.service.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { BlogPost, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
import {
|
||||
BlogPostRepository,
|
||||
BlogPostFilters,
|
||||
BLOG_POST_REPOSITORY,
|
||||
} from '@domain/ports/out/blog-post.repository';
|
||||
import { CreateBlogPostDto, UpdateBlogPostDto } from '../dto/blog-post.dto';
|
||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||
|
||||
const BLOG_IMAGES_BUCKET = 'xpeditis-blog';
|
||||
|
||||
@Injectable()
|
||||
export class BlogService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(BlogService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(BLOG_POST_REPOSITORY)
|
||||
private readonly blogPostRepository: BlogPostRepository,
|
||||
@Inject(STORAGE_PORT)
|
||||
private readonly storage: StoragePort
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
await this.storage.ensureBucket(BLOG_IMAGES_BUCKET);
|
||||
this.logger.log(`Blog images bucket "${BLOG_IMAGES_BUCKET}" is ready`);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Could not ensure blog images bucket: ${err?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createPost(dto: CreateBlogPostDto): Promise<BlogPost> {
|
||||
const slugTaken = await this.blogPostRepository.slugExists(dto.slug);
|
||||
if (slugTaken) {
|
||||
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
|
||||
}
|
||||
|
||||
let post = BlogPost.create({
|
||||
id: uuidv4(),
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
coverImageUrl: dto.coverImageUrl,
|
||||
category: dto.category,
|
||||
tags: dto.tags ?? [],
|
||||
authorName: dto.authorName,
|
||||
metaTitle: dto.metaTitle,
|
||||
metaDescription: dto.metaDescription,
|
||||
primaryKeyword: dto.primaryKeyword,
|
||||
secondaryKeywords: dto.secondaryKeywords ?? [],
|
||||
});
|
||||
|
||||
if (dto.scheduledAt) {
|
||||
const schedDate = new Date(dto.scheduledAt);
|
||||
post = schedDate > new Date() ? post.schedule(schedDate) : post.publish();
|
||||
}
|
||||
|
||||
return this.blogPostRepository.save(post);
|
||||
}
|
||||
|
||||
async updatePost(id: string, dto: UpdateBlogPostDto): Promise<BlogPost> {
|
||||
const post = await this.findOrFail(id);
|
||||
|
||||
if (dto.slug && dto.slug !== post.slug) {
|
||||
const slugTaken = await this.blogPostRepository.slugExists(dto.slug, id);
|
||||
if (slugTaken) {
|
||||
throw new ConflictException(`Slug "${dto.slug}" is already in use`);
|
||||
}
|
||||
}
|
||||
|
||||
let updated = post.update({
|
||||
title: dto.title,
|
||||
slug: dto.slug,
|
||||
excerpt: dto.excerpt,
|
||||
content: dto.content,
|
||||
coverImageUrl: dto.coverImageUrl,
|
||||
category: dto.category,
|
||||
tags: dto.tags,
|
||||
authorName: dto.authorName,
|
||||
isFeatured: dto.isFeatured,
|
||||
metaTitle: dto.metaTitle,
|
||||
metaDescription: dto.metaDescription,
|
||||
primaryKeyword: dto.primaryKeyword,
|
||||
secondaryKeywords: dto.secondaryKeywords,
|
||||
});
|
||||
|
||||
if (dto.scheduledAt) {
|
||||
const schedDate = new Date(dto.scheduledAt);
|
||||
updated = schedDate > new Date() ? updated.schedule(schedDate) : updated.publish();
|
||||
} else if (dto.status !== undefined && dto.status !== post.status) {
|
||||
if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish();
|
||||
else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive();
|
||||
else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish();
|
||||
else if (dto.status === BlogPostStatus.SCHEDULED && post.publishedAt)
|
||||
updated = updated.schedule(post.publishedAt);
|
||||
}
|
||||
|
||||
return this.blogPostRepository.save(updated);
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await this.findOrFail(id);
|
||||
await this.blogPostRepository.delete(id);
|
||||
}
|
||||
|
||||
async getPostById(id: string): Promise<BlogPost> {
|
||||
return this.findOrFail(id);
|
||||
}
|
||||
|
||||
async getPublishedPostBySlug(slug: string): Promise<BlogPost> {
|
||||
const post = await this.blogPostRepository.findBySlug(slug);
|
||||
if (!post || !post.isVisibleToPublic()) {
|
||||
throw new NotFoundException('Article not found');
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
async listPublishedPosts(
|
||||
filters: BlogPostFilters
|
||||
): Promise<{ posts: BlogPost[]; total: number }> {
|
||||
const publishedFilters: BlogPostFilters = {
|
||||
...filters,
|
||||
status: BlogPostStatus.PUBLISHED,
|
||||
includeScheduled: true,
|
||||
};
|
||||
const [posts, total] = await Promise.all([
|
||||
this.blogPostRepository.findByFilters(publishedFilters),
|
||||
this.blogPostRepository.count(publishedFilters),
|
||||
]);
|
||||
return { posts, total };
|
||||
}
|
||||
|
||||
async listAllPosts(filters: BlogPostFilters): Promise<{ posts: BlogPost[]; total: number }> {
|
||||
const [posts, total] = await Promise.all([
|
||||
this.blogPostRepository.findByFilters(filters),
|
||||
this.blogPostRepository.count(filters),
|
||||
]);
|
||||
return { posts, total };
|
||||
}
|
||||
|
||||
private async findOrFail(id: string): Promise<BlogPost> {
|
||||
const post = await this.blogPostRepository.findById(id);
|
||||
if (!post) {
|
||||
throw new NotFoundException(`Blog post with id "${id}" not found`);
|
||||
}
|
||||
return post;
|
||||
}
|
||||
}
|
||||
@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Carrier Auth Service
|
||||
*
|
||||
* Handles carrier authentication and automatic account creation
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, UnauthorizedException, Inject } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class CarrierAuthService {
|
||||
private readonly logger = new Logger(CarrierAuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly carrierProfileRepository: CarrierProfileRepository,
|
||||
@InjectRepository(UserOrmEntity)
|
||||
private readonly userRepository: Repository<UserOrmEntity>,
|
||||
@InjectRepository(OrganizationOrmEntity)
|
||||
private readonly organizationRepository: Repository<OrganizationOrmEntity>,
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(EMAIL_PORT)
|
||||
private readonly emailAdapter: EmailPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create carrier account automatically when clicking accept/reject link
|
||||
*/
|
||||
async createCarrierAccountIfNotExists(
|
||||
carrierEmail: string,
|
||||
carrierName: string
|
||||
): Promise<{
|
||||
carrierId: string;
|
||||
userId: string;
|
||||
isNewAccount: boolean;
|
||||
temporaryPassword?: string;
|
||||
}> {
|
||||
this.logger.log(`Checking/creating carrier account for: ${carrierEmail}`);
|
||||
|
||||
// Check if carrier already exists
|
||||
const existingCarrier = await this.carrierProfileRepository.findByEmail(carrierEmail);
|
||||
|
||||
if (existingCarrier) {
|
||||
this.logger.log(`Carrier already exists: ${carrierEmail}`);
|
||||
return {
|
||||
carrierId: existingCarrier.id,
|
||||
userId: existingCarrier.userId,
|
||||
isNewAccount: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Create new organization for the carrier
|
||||
const organizationId = uuidv4(); // Generate UUID for organization
|
||||
const organization = this.organizationRepository.create({
|
||||
id: organizationId, // Provide explicit ID since @PrimaryColumn requires it
|
||||
name: carrierName,
|
||||
type: 'CARRIER',
|
||||
isCarrier: true,
|
||||
carrierType: 'LCL', // Default
|
||||
addressStreet: 'TBD',
|
||||
addressCity: 'TBD',
|
||||
addressPostalCode: 'TBD',
|
||||
addressCountry: 'FR', // Default to France
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const savedOrganization = await this.organizationRepository.save(organization);
|
||||
this.logger.log(`Created organization: ${savedOrganization.id}`);
|
||||
|
||||
// Generate temporary password
|
||||
const temporaryPassword = this.generateTemporaryPassword();
|
||||
const hashedPassword = await argon2.hash(temporaryPassword);
|
||||
|
||||
// Create user account
|
||||
const nameParts = carrierName.split(' ');
|
||||
const user = this.userRepository.create({
|
||||
id: uuidv4(),
|
||||
email: carrierEmail.toLowerCase(),
|
||||
passwordHash: hashedPassword,
|
||||
firstName: nameParts[0] || 'Carrier',
|
||||
lastName: nameParts.slice(1).join(' ') || 'Account',
|
||||
role: 'CARRIER', // New role for carriers
|
||||
organizationId: savedOrganization.id,
|
||||
isActive: true,
|
||||
isEmailVerified: true, // Auto-verified since created via email
|
||||
});
|
||||
|
||||
const savedUser = await this.userRepository.save(user);
|
||||
this.logger.log(`Created user: ${savedUser.id}`);
|
||||
|
||||
// Create carrier profile
|
||||
const carrierProfile = await this.carrierProfileRepository.create({
|
||||
userId: savedUser.id,
|
||||
organizationId: savedOrganization.id,
|
||||
companyName: carrierName,
|
||||
notificationEmail: carrierEmail,
|
||||
preferredCurrency: 'USD',
|
||||
isActive: true,
|
||||
isVerified: false, // Will be verified later
|
||||
});
|
||||
|
||||
this.logger.log(`Created carrier profile: ${carrierProfile.id}`);
|
||||
|
||||
// Send welcome email with credentials and WAIT for confirmation
|
||||
try {
|
||||
await this.emailAdapter.sendCarrierAccountCreated(
|
||||
carrierEmail,
|
||||
carrierName,
|
||||
temporaryPassword
|
||||
);
|
||||
this.logger.log(`Account creation email sent to ${carrierEmail}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send account creation email: ${error?.message}`, error?.stack);
|
||||
// Continue even if email fails - account is already created
|
||||
}
|
||||
|
||||
return {
|
||||
carrierId: carrierProfile.id,
|
||||
userId: savedUser.id,
|
||||
isNewAccount: true,
|
||||
temporaryPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate auto-login JWT token for carrier
|
||||
*/
|
||||
async generateAutoLoginToken(userId: string, carrierId: string): Promise<string> {
|
||||
this.logger.log(`Generating auto-login token for carrier: ${carrierId}`);
|
||||
|
||||
const payload = {
|
||||
sub: userId,
|
||||
carrierId,
|
||||
type: 'carrier',
|
||||
autoLogin: true,
|
||||
};
|
||||
|
||||
const token = this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||
this.logger.log(`Auto-login token generated for carrier: ${carrierId}`);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard login for carriers
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
carrier: {
|
||||
id: string;
|
||||
companyName: string;
|
||||
email: string;
|
||||
};
|
||||
}> {
|
||||
this.logger.log(`Carrier login attempt: ${email}`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findByEmail(email);
|
||||
|
||||
if (!carrier || !carrier.user) {
|
||||
this.logger.warn(`Login failed: Carrier not found for email ${email}`);
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await argon2.verify(carrier.user.passwordHash, password);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
this.logger.warn(`Login failed: Invalid password for ${email}`);
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if carrier is active
|
||||
if (!carrier.isActive) {
|
||||
this.logger.warn(`Login failed: Carrier account is inactive ${email}`);
|
||||
throw new UnauthorizedException('Account is inactive');
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await this.carrierProfileRepository.updateLastLogin(carrier.id);
|
||||
|
||||
// Generate JWT tokens
|
||||
const payload = {
|
||||
sub: carrier.userId,
|
||||
email: carrier.user.email,
|
||||
carrierId: carrier.id,
|
||||
organizationId: carrier.organizationId,
|
||||
role: 'CARRIER',
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' });
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||
|
||||
this.logger.log(`Login successful for carrier: ${carrier.id}`);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
carrier: {
|
||||
id: carrier.id,
|
||||
companyName: carrier.companyName,
|
||||
email: carrier.user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify auto-login token
|
||||
*/
|
||||
async verifyAutoLoginToken(token: string): Promise<{
|
||||
userId: string;
|
||||
carrierId: string;
|
||||
}> {
|
||||
try {
|
||||
const payload = this.jwtService.verify(token);
|
||||
|
||||
if (!payload.autoLogin || payload.type !== 'carrier') {
|
||||
throw new UnauthorizedException('Invalid auto-login token');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
carrierId: payload.carrierId,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Auto-login token verification failed: ${error?.message}`);
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change carrier password
|
||||
*/
|
||||
async changePassword(carrierId: string, oldPassword: string, newPassword: string): Promise<void> {
|
||||
this.logger.log(`Password change request for carrier: ${carrierId}`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findById(carrierId);
|
||||
|
||||
if (!carrier || !carrier.user) {
|
||||
throw new UnauthorizedException('Carrier not found');
|
||||
}
|
||||
|
||||
// Verify old password
|
||||
const isOldPasswordValid = await argon2.verify(carrier.user.passwordHash, oldPassword);
|
||||
|
||||
if (!isOldPasswordValid) {
|
||||
this.logger.warn(`Password change failed: Invalid old password for carrier ${carrierId}`);
|
||||
throw new UnauthorizedException('Invalid old password');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedNewPassword = await argon2.hash(newPassword);
|
||||
|
||||
// Update password
|
||||
carrier.user.passwordHash = hashedNewPassword;
|
||||
await this.userRepository.save(carrier.user);
|
||||
|
||||
this.logger.log(`Password changed successfully for carrier: ${carrierId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset (sends temporary password via email)
|
||||
*/
|
||||
async requestPasswordReset(email: string): Promise<{ temporaryPassword: string }> {
|
||||
this.logger.log(`Password reset request for: ${email}`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findByEmail(email);
|
||||
|
||||
if (!carrier || !carrier.user) {
|
||||
// Don't reveal if email exists or not for security
|
||||
this.logger.warn(`Password reset requested for non-existent carrier: ${email}`);
|
||||
throw new UnauthorizedException('If this email exists, a password reset will be sent');
|
||||
}
|
||||
|
||||
// Generate temporary password
|
||||
const temporaryPassword = this.generateTemporaryPassword();
|
||||
const hashedPassword = await argon2.hash(temporaryPassword);
|
||||
|
||||
// Update password
|
||||
carrier.user.passwordHash = hashedPassword;
|
||||
await this.userRepository.save(carrier.user);
|
||||
|
||||
this.logger.log(`Temporary password generated for carrier: ${carrier.id}`);
|
||||
|
||||
// Send password reset email and WAIT for confirmation
|
||||
try {
|
||||
await this.emailAdapter.sendCarrierPasswordReset(
|
||||
email,
|
||||
carrier.companyName,
|
||||
temporaryPassword
|
||||
);
|
||||
this.logger.log(`Password reset email sent to ${email}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to send password reset email: ${error?.message}`, error?.stack);
|
||||
// Continue even if email fails - password is already reset
|
||||
}
|
||||
|
||||
return { temporaryPassword };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure temporary password
|
||||
*/
|
||||
private generateTemporaryPassword(): string {
|
||||
return randomBytes(16).toString('hex').slice(0, 12);
|
||||
}
|
||||
}
|
||||
@ -989,7 +989,9 @@ export class CsvBookingService {
|
||||
|
||||
for (const file of files) {
|
||||
const documentId = uuidv4();
|
||||
const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`;
|
||||
// Multer decodes originalname as latin1; re-encode to utf8 to preserve accented characters
|
||||
const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||
const fileKey = `csv-bookings/${bookingId}/${documentId}-${fileName}`;
|
||||
|
||||
// Upload to S3
|
||||
const uploadResult = await this.storageAdapter.upload({
|
||||
@ -1000,12 +1002,12 @@ export class CsvBookingService {
|
||||
});
|
||||
|
||||
// Determine document type from filename or default to OTHER
|
||||
const documentType = this.inferDocumentType(file.originalname);
|
||||
const documentType = this.inferDocumentType(fileName);
|
||||
|
||||
const document = new CsvBookingDocumentImpl(
|
||||
documentId,
|
||||
documentType,
|
||||
file.originalname,
|
||||
fileName,
|
||||
uploadResult.url,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
@ -1064,9 +1066,10 @@ export class CsvBookingService {
|
||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||
}
|
||||
|
||||
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
|
||||
// Allow adding documents to PENDING_PAYMENT, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings
|
||||
if (
|
||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
||||
booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER &&
|
||||
booking.status !== CsvBookingStatus.PENDING &&
|
||||
booking.status !== CsvBookingStatus.ACCEPTED
|
||||
) {
|
||||
|
||||
179
apps/backend/src/domain/entities/blog-post.entity.ts
Normal file
179
apps/backend/src/domain/entities/blog-post.entity.ts
Normal file
@ -0,0 +1,179 @@
|
||||
export enum BlogPostStatus {
|
||||
DRAFT = 'draft',
|
||||
SCHEDULED = 'scheduled',
|
||||
PUBLISHED = 'published',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export type BlogPostCategory = 'industry' | 'technology' | 'guides' | 'news';
|
||||
|
||||
interface BlogPostProps {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
coverImageUrl?: string;
|
||||
category: BlogPostCategory;
|
||||
tags: string[];
|
||||
authorName: string;
|
||||
status: BlogPostStatus;
|
||||
isFeatured: boolean;
|
||||
publishedAt?: Date;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
primaryKeyword?: string;
|
||||
secondaryKeywords: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class BlogPost {
|
||||
private constructor(private readonly props: BlogPostProps) {}
|
||||
|
||||
static create(
|
||||
props: Omit<BlogPostProps, 'status' | 'isFeatured' | 'publishedAt' | 'createdAt' | 'updatedAt'>
|
||||
): BlogPost {
|
||||
const now = new Date();
|
||||
return new BlogPost({
|
||||
...props,
|
||||
secondaryKeywords: props.secondaryKeywords ?? [],
|
||||
status: BlogPostStatus.DRAFT,
|
||||
isFeatured: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
static fromPersistence(props: BlogPostProps): BlogPost {
|
||||
return new BlogPost(props);
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.props.id;
|
||||
}
|
||||
get title(): string {
|
||||
return this.props.title;
|
||||
}
|
||||
get slug(): string {
|
||||
return this.props.slug;
|
||||
}
|
||||
get excerpt(): string {
|
||||
return this.props.excerpt;
|
||||
}
|
||||
get content(): string {
|
||||
return this.props.content;
|
||||
}
|
||||
get coverImageUrl(): string | undefined {
|
||||
return this.props.coverImageUrl;
|
||||
}
|
||||
get category(): BlogPostCategory {
|
||||
return this.props.category;
|
||||
}
|
||||
get tags(): string[] {
|
||||
return this.props.tags;
|
||||
}
|
||||
get authorName(): string {
|
||||
return this.props.authorName;
|
||||
}
|
||||
get status(): BlogPostStatus {
|
||||
return this.props.status;
|
||||
}
|
||||
get isFeatured(): boolean {
|
||||
return this.props.isFeatured;
|
||||
}
|
||||
get publishedAt(): Date | undefined {
|
||||
return this.props.publishedAt;
|
||||
}
|
||||
get metaTitle(): string | undefined {
|
||||
return this.props.metaTitle;
|
||||
}
|
||||
get metaDescription(): string | undefined {
|
||||
return this.props.metaDescription;
|
||||
}
|
||||
get primaryKeyword(): string | undefined {
|
||||
return this.props.primaryKeyword;
|
||||
}
|
||||
get secondaryKeywords(): string[] {
|
||||
return this.props.secondaryKeywords;
|
||||
}
|
||||
get createdAt(): Date {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
get updatedAt(): Date {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
update(
|
||||
data: Partial<
|
||||
Pick<
|
||||
BlogPostProps,
|
||||
| 'title'
|
||||
| 'slug'
|
||||
| 'excerpt'
|
||||
| 'content'
|
||||
| 'coverImageUrl'
|
||||
| 'category'
|
||||
| 'tags'
|
||||
| 'authorName'
|
||||
| 'isFeatured'
|
||||
| 'metaTitle'
|
||||
| 'metaDescription'
|
||||
| 'primaryKeyword'
|
||||
| 'secondaryKeywords'
|
||||
>
|
||||
>
|
||||
): BlogPost {
|
||||
return new BlogPost({ ...this.props, ...data, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
publish(): BlogPost {
|
||||
return new BlogPost({
|
||||
...this.props,
|
||||
status: BlogPostStatus.PUBLISHED,
|
||||
publishedAt: this.props.publishedAt ?? new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
schedule(date: Date): BlogPost {
|
||||
return new BlogPost({
|
||||
...this.props,
|
||||
status: BlogPostStatus.SCHEDULED,
|
||||
publishedAt: date,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
archive(): BlogPost {
|
||||
return new BlogPost({ ...this.props, status: BlogPostStatus.ARCHIVED, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
unpublish(): BlogPost {
|
||||
return new BlogPost({
|
||||
...this.props,
|
||||
status: BlogPostStatus.DRAFT,
|
||||
publishedAt: undefined,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
isPublished(): boolean {
|
||||
return this.props.status === BlogPostStatus.PUBLISHED;
|
||||
}
|
||||
|
||||
isVisibleToPublic(): boolean {
|
||||
if (this.props.status === BlogPostStatus.PUBLISHED) return true;
|
||||
if (
|
||||
this.props.status === BlogPostStatus.SCHEDULED &&
|
||||
this.props.publishedAt &&
|
||||
this.props.publishedAt <= new Date()
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
toObject(): BlogPostProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
@ -7,3 +7,4 @@
|
||||
export * from './search-rates.port';
|
||||
export * from './get-ports.port';
|
||||
export * from './validate-availability.port';
|
||||
export * from './search-csv-rates.port';
|
||||
|
||||
24
apps/backend/src/domain/ports/out/blog-post.repository.ts
Normal file
24
apps/backend/src/domain/ports/out/blog-post.repository.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
|
||||
export const BLOG_POST_REPOSITORY = 'BlogPostRepository';
|
||||
|
||||
export interface BlogPostFilters {
|
||||
status?: BlogPostStatus;
|
||||
category?: BlogPostCategory;
|
||||
search?: string;
|
||||
isFeatured?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** When true, also include SCHEDULED posts whose publishedAt <= now */
|
||||
includeScheduled?: boolean;
|
||||
}
|
||||
|
||||
export interface BlogPostRepository {
|
||||
save(post: BlogPost): Promise<BlogPost>;
|
||||
findById(id: string): Promise<BlogPost | null>;
|
||||
findBySlug(slug: string): Promise<BlogPost | null>;
|
||||
findByFilters(filters: BlogPostFilters): Promise<BlogPost[]>;
|
||||
count(filters: BlogPostFilters): Promise<number>;
|
||||
delete(id: string): Promise<void>;
|
||||
slugExists(slug: string, excludeId?: string): Promise<boolean>;
|
||||
}
|
||||
@ -15,6 +15,11 @@ export * from './notification.repository';
|
||||
export * from './audit-log.repository';
|
||||
export * from './webhook.repository';
|
||||
export * from './csv-booking.repository';
|
||||
export * from './api-key.repository';
|
||||
export * from './blog-post.repository';
|
||||
export * from './invitation-token.repository';
|
||||
export * from './subscription.repository';
|
||||
export * from './license.repository';
|
||||
|
||||
// Infrastructure Ports
|
||||
export * from './cache.port';
|
||||
@ -23,6 +28,6 @@ export * from './pdf.port';
|
||||
export * from './storage.port';
|
||||
export * from './carrier-connector.port';
|
||||
export * from './csv-rate-loader.port';
|
||||
export * from './subscription.repository';
|
||||
export * from './license.repository';
|
||||
export * from './shipment-counter.port';
|
||||
export * from './siret-verification.port';
|
||||
export * from './stripe.port';
|
||||
|
||||
@ -66,4 +66,9 @@ export interface StoragePort {
|
||||
* List objects in a bucket
|
||||
*/
|
||||
list(bucket: string, prefix?: string): Promise<StorageObject[]>;
|
||||
|
||||
/**
|
||||
* Ensure a bucket exists, creating it if it does not
|
||||
*/
|
||||
ensureBucket(bucket: string): Promise<void>;
|
||||
}
|
||||
|
||||
@ -8,3 +8,6 @@ export * from './rate-search.service';
|
||||
export * from './port-search.service';
|
||||
export * from './availability-validation.service';
|
||||
export * from './booking.service';
|
||||
export * from './csv-rate-search.service';
|
||||
export * from './csv-rate-price-calculator.service';
|
||||
export * from './rate-offer-generator.service';
|
||||
|
||||
@ -15,3 +15,6 @@ export * from './subscription-plan.vo';
|
||||
export * from './subscription-status.vo';
|
||||
export * from './license-status.vo';
|
||||
export * from './locale.vo';
|
||||
export * from './surcharge.vo';
|
||||
export * from './volume.vo';
|
||||
export * from './plan-feature.vo';
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
|
||||
|
||||
@Entity('blog_posts')
|
||||
@Index(['status', 'published_at'])
|
||||
@Index(['slug'], { unique: true })
|
||||
export class BlogPostOrmEntity {
|
||||
@PrimaryColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column('varchar', { length: 255, unique: true })
|
||||
slug: string;
|
||||
|
||||
@Column('text')
|
||||
excerpt: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column('varchar', { length: 500, nullable: true })
|
||||
cover_image_url?: string;
|
||||
|
||||
@Column('varchar', { length: 50 })
|
||||
category: string;
|
||||
|
||||
@Column('jsonb', { default: [] })
|
||||
tags: string[];
|
||||
|
||||
@Column('varchar', { length: 255 })
|
||||
author_name: string;
|
||||
|
||||
@Column('varchar', { length: 20, default: 'draft' })
|
||||
status: string;
|
||||
|
||||
@Column('boolean', { default: false })
|
||||
is_featured: boolean;
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
published_at?: Date;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
meta_title?: string;
|
||||
|
||||
@Column('varchar', { length: 500, nullable: true })
|
||||
meta_description?: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
primary_keyword?: string;
|
||||
|
||||
@Column('jsonb', { default: [] })
|
||||
secondary_keywords: string[];
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateBlogPostsTable1746000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'blog_posts',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
isUnique: true,
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'text',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'cover_image_url',
|
||||
type: 'varchar',
|
||||
length: '500',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'jsonb',
|
||||
isNullable: false,
|
||||
default: "'[]'",
|
||||
},
|
||||
{
|
||||
name: 'author_name',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: false,
|
||||
default: "'draft'",
|
||||
},
|
||||
{
|
||||
name: 'is_featured',
|
||||
type: 'boolean',
|
||||
isNullable: false,
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'published_at',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'created_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'updated_at',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
isNullable: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'blog_posts',
|
||||
new TableIndex({
|
||||
name: 'idx_blog_posts_status_published_at',
|
||||
columnNames: ['status', 'published_at'],
|
||||
})
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'blog_posts',
|
||||
new TableIndex({
|
||||
name: 'idx_blog_posts_category_status',
|
||||
columnNames: ['category', 'status'],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('blog_posts');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
export class AddSeoFieldsToBlogPosts1747000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.addColumns('blog_posts', [
|
||||
new TableColumn({
|
||||
name: 'meta_title',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: 'meta_description',
|
||||
type: 'varchar',
|
||||
length: '500',
|
||||
isNullable: true,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: 'primary_keyword',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
}),
|
||||
new TableColumn({
|
||||
name: 'secondary_keywords',
|
||||
type: 'jsonb',
|
||||
isNullable: false,
|
||||
default: "'[]'",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropColumns('blog_posts', [
|
||||
'meta_title',
|
||||
'meta_description',
|
||||
'primary_keyword',
|
||||
'secondary_keywords',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BlogPost, BlogPostCategory, BlogPostStatus } from '@domain/entities/blog-post.entity';
|
||||
import { BlogPostFilters, BlogPostRepository } from '@domain/ports/out/blog-post.repository';
|
||||
import { BlogPostOrmEntity } from '../entities/blog-post.orm-entity';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmBlogPostRepository implements BlogPostRepository {
|
||||
constructor(
|
||||
@InjectRepository(BlogPostOrmEntity)
|
||||
private readonly ormRepository: Repository<BlogPostOrmEntity>
|
||||
) {}
|
||||
|
||||
async save(post: BlogPost): Promise<BlogPost> {
|
||||
const orm = this.toOrm(post);
|
||||
const saved = await this.ormRepository.save(orm);
|
||||
return this.toDomain(saved);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BlogPost | null> {
|
||||
const orm = await this.ormRepository.findOne({ where: { id } });
|
||||
return orm ? this.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<BlogPost | null> {
|
||||
const orm = await this.ormRepository.findOne({ where: { slug } });
|
||||
return orm ? this.toDomain(orm) : null;
|
||||
}
|
||||
|
||||
async findByFilters(filters: BlogPostFilters): Promise<BlogPost[]> {
|
||||
const query = this.ormRepository.createQueryBuilder('post');
|
||||
|
||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||
query.andWhere(
|
||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||
);
|
||||
} else if (filters.status) {
|
||||
query.andWhere('post.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
if (filters.category) {
|
||||
query.andWhere('post.category = :category', { category: filters.category });
|
||||
}
|
||||
|
||||
if (filters.isFeatured !== undefined) {
|
||||
query.andWhere('post.is_featured = :isFeatured', { isFeatured: filters.isFeatured });
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
query.orderBy('post.published_at', 'DESC').addOrderBy('post.created_at', 'DESC');
|
||||
|
||||
if (filters.offset) query.skip(filters.offset);
|
||||
if (filters.limit) query.take(filters.limit);
|
||||
|
||||
const results = await query.getMany();
|
||||
return results.map(e => this.toDomain(e));
|
||||
}
|
||||
|
||||
async count(filters: BlogPostFilters): Promise<number> {
|
||||
const query = this.ormRepository.createQueryBuilder('post');
|
||||
|
||||
if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) {
|
||||
query.andWhere(
|
||||
"(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))"
|
||||
);
|
||||
} else if (filters.status) {
|
||||
query.andWhere('post.status = :status', { status: filters.status });
|
||||
}
|
||||
if (filters.category) {
|
||||
query.andWhere('post.category = :category', { category: filters.category });
|
||||
}
|
||||
if (filters.search) {
|
||||
query.andWhere('(post.title ILIKE :search OR post.excerpt ILIKE :search)', {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
return query.getCount();
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.ormRepository.delete(id);
|
||||
}
|
||||
|
||||
async slugExists(slug: string, excludeId?: string): Promise<boolean> {
|
||||
const query = this.ormRepository
|
||||
.createQueryBuilder('post')
|
||||
.where('post.slug = :slug', { slug });
|
||||
if (excludeId) {
|
||||
query.andWhere('post.id != :excludeId', { excludeId });
|
||||
}
|
||||
const count = await query.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
private toDomain(orm: BlogPostOrmEntity): BlogPost {
|
||||
return BlogPost.fromPersistence({
|
||||
id: orm.id,
|
||||
title: orm.title,
|
||||
slug: orm.slug,
|
||||
excerpt: orm.excerpt,
|
||||
content: orm.content,
|
||||
coverImageUrl: orm.cover_image_url,
|
||||
category: orm.category as BlogPostCategory,
|
||||
tags: orm.tags ?? [],
|
||||
authorName: orm.author_name,
|
||||
status: orm.status as BlogPostStatus,
|
||||
isFeatured: orm.is_featured,
|
||||
publishedAt: orm.published_at,
|
||||
metaTitle: orm.meta_title,
|
||||
metaDescription: orm.meta_description,
|
||||
primaryKeyword: orm.primary_keyword,
|
||||
secondaryKeywords: orm.secondary_keywords ?? [],
|
||||
createdAt: orm.created_at,
|
||||
updatedAt: orm.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
private toOrm(post: BlogPost): BlogPostOrmEntity {
|
||||
const orm = new BlogPostOrmEntity();
|
||||
orm.id = post.id;
|
||||
orm.title = post.title;
|
||||
orm.slug = post.slug;
|
||||
orm.excerpt = post.excerpt;
|
||||
orm.content = post.content;
|
||||
orm.cover_image_url = post.coverImageUrl;
|
||||
orm.category = post.category;
|
||||
orm.tags = post.tags;
|
||||
orm.author_name = post.authorName;
|
||||
orm.status = post.status;
|
||||
orm.is_featured = post.isFeatured;
|
||||
orm.published_at = post.publishedAt;
|
||||
orm.meta_title = post.metaTitle;
|
||||
orm.meta_description = post.metaDescription;
|
||||
orm.primary_keyword = post.primaryKeyword;
|
||||
orm.secondary_keywords = post.secondaryKeywords;
|
||||
orm.created_at = post.createdAt;
|
||||
orm.updated_at = post.updatedAt;
|
||||
return orm;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ import {
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
HeadBucketCommand,
|
||||
CreateBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
@ -70,6 +72,23 @@ export class S3StorageAdapter implements StoragePort {
|
||||
);
|
||||
}
|
||||
|
||||
async ensureBucket(bucket: string): Promise<void> {
|
||||
if (!this.s3Client) return;
|
||||
|
||||
try {
|
||||
await this.s3Client.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
} catch (err: any) {
|
||||
const status = err.$metadata?.httpStatusCode;
|
||||
if (status === 404 || err.name === 'NoSuchBucket' || err.name === 'NotFound') {
|
||||
this.logger.log(`Bucket "${bucket}" not found — creating it automatically`);
|
||||
await this.s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
this.logger.log(`Bucket "${bucket}" created`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async upload(options: UploadOptions): Promise<StorageObject> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error(
|
||||
@ -77,6 +96,8 @@ export class S3StorageAdapter implements StoragePort {
|
||||
);
|
||||
}
|
||||
|
||||
await this.ensureBucket(options.bucket);
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: options.bucket,
|
||||
@ -108,6 +129,12 @@ export class S3StorageAdapter implements StoragePort {
|
||||
}
|
||||
|
||||
async download(options: DownloadOptions): Promise<Buffer> {
|
||||
if (!this.s3Client) {
|
||||
throw new Error(
|
||||
'S3 Storage is not configured. Set AWS_S3_ENDPOINT or AWS credentials in .env'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: options.bucket,
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'sandbox.smtp.mailtrap.io',
|
||||
port: 2525,
|
||||
auth: {
|
||||
user: '2597bd31d265eb',
|
||||
pass: 'cd126234193c89'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔄 Tentative d\'envoi d\'email...');
|
||||
|
||||
transporter.sendMail({
|
||||
from: 'noreply@xpeditis.com',
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Email depuis Portail Transporteur',
|
||||
text: 'Email de test pour vérifier la configuration'
|
||||
}).then(info => {
|
||||
console.log('✅ Email envoyé:', info.messageId);
|
||||
console.log('📧 Response:', info.response);
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('❌ Erreur:', err.message);
|
||||
console.error('Code:', err.code);
|
||||
console.error('Command:', err.command);
|
||||
console.error('Stack:', err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -90,7 +90,10 @@ export default function AboutPage() {
|
||||
<LandingHeader activePage="about" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -155,9 +158,7 @@ export default function AboutPage() {
|
||||
<Target className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
{t('mission.body')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">{t('mission.body')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -168,9 +169,7 @@ export default function AboutPage() {
|
||||
<Eye className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">
|
||||
{t('vision.body')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-lg leading-relaxed">{t('vision.body')}</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -186,11 +185,7 @@ export default function AboutPage() {
|
||||
>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{STATS.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.key}
|
||||
variants={itemVariants}
|
||||
className="text-center"
|
||||
>
|
||||
<motion.div key={stat.key} variants={itemVariants} className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={isStatsInView ? { scale: 1 } : {}}
|
||||
@ -215,10 +210,10 @@ export default function AboutPage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('valuesSubtitle')}
|
||||
</p>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
{t('valuesTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -227,7 +222,7 @@ export default function AboutPage() {
|
||||
animate={isValuesInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
{VALUES.map((value) => {
|
||||
{VALUES.map(value => {
|
||||
const IconComponent = value.icon;
|
||||
return (
|
||||
<motion.div
|
||||
@ -241,7 +236,9 @@ export default function AboutPage() {
|
||||
>
|
||||
<IconComponent className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||
{t(`values.${value.key}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
|
||||
</motion.div>
|
||||
);
|
||||
@ -259,10 +256,10 @@ export default function AboutPage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('timelineSubtitle')}
|
||||
</p>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
{t('timelineTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative">
|
||||
@ -286,13 +283,19 @@ export default function AboutPage() {
|
||||
transition={{ duration: 0.7, ease: 'easeOut' }}
|
||||
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
|
||||
>
|
||||
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}>
|
||||
<div
|
||||
className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}
|
||||
>
|
||||
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
|
||||
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
|
||||
<div
|
||||
className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-brand-turquoise" />
|
||||
<span className="text-2xl font-bold text-brand-turquoise">{year}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">
|
||||
{t(`timeline.${year}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -302,7 +305,13 @@ export default function AboutPage() {
|
||||
initial={{ scale: 0 }}
|
||||
whileInView={{ scale: 1 }}
|
||||
viewport={{ once: true, amount: 0.6 }}
|
||||
transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.15,
|
||||
type: 'spring',
|
||||
stiffness: 320,
|
||||
damping: 18,
|
||||
}}
|
||||
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
|
||||
/>
|
||||
</div>
|
||||
@ -324,10 +333,10 @@ export default function AboutPage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('teamSubtitle')}
|
||||
</p>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
{t('teamTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -336,7 +345,7 @@ export default function AboutPage() {
|
||||
animate={isTeamInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{TEAM.map((member) => (
|
||||
{TEAM.map(member => (
|
||||
<motion.div
|
||||
key={member.key}
|
||||
variants={itemVariants}
|
||||
@ -358,7 +367,9 @@ export default function AboutPage() {
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
|
||||
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p>
|
||||
<p className="text-brand-turquoise font-medium mb-3">
|
||||
{t(`team.${member.key}.role`)}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
@ -376,12 +387,8 @@ export default function AboutPage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-10">
|
||||
{t('cta.body')}
|
||||
</p>
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">{t('cta.title')}</h2>
|
||||
<p className="text-xl text-white/80 mb-10">{t('cta.body')}</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
|
||||
<Link
|
||||
href="/register"
|
||||
|
||||
301
apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx
Normal file
301
apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Calendar, User, Tag, Clock, Share2, BookOpen, Anchor } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { getBlogPost, type BlogPost } from '@/lib/api/blog';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
function resolveUrl(url: string | null | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('http')) return url;
|
||||
return `${API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function estimateReadingTime(content: string): number {
|
||||
const text = content.replace(/<[^>]*>/g, '');
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
industry: 'Industrie',
|
||||
technology: 'Technologie',
|
||||
guides: 'Guides',
|
||||
news: 'Actualités',
|
||||
};
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||
<div className="h-10 bg-gray-200 rounded w-3/4" />
|
||||
<div className="h-5 bg-gray-200 rounded w-1/2" />
|
||||
<div className="h-72 bg-gray-200 rounded-2xl mt-8" />
|
||||
<div className="space-y-3 mt-8">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-4 bg-gray-200 rounded" style={{ width: `${85 + (i % 3) * 5}%` }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundView() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20 text-center">
|
||||
<Anchor className="w-24 h-24 text-gray-200 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-brand-navy mb-4">Article introuvable</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Cet article n'existe pas ou n'est pas encore publié.
|
||||
</p>
|
||||
<Link href="/blog">
|
||||
<span className="inline-flex items-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Retour au blog</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPostContent({ slug }: { slug: string }) {
|
||||
const [post, setPost] = useState<BlogPost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
let cancelled = false;
|
||||
|
||||
getBlogPost(slug)
|
||||
.then(p => {
|
||||
if (!cancelled) setPost(p);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNotFound(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [slug]);
|
||||
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
if (notFound || !post) return <NotFoundView />;
|
||||
|
||||
const readingTime = estimateReadingTime(post.content);
|
||||
const processedContent = post.content.replace(
|
||||
/src="(\/api\/v1\/blog\/images\/[^"]+)"/g,
|
||||
`src="${API_BASE_URL}$1"`
|
||||
);
|
||||
|
||||
const handleShare = () => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ title: post.title, url: window.location.href });
|
||||
} else {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy via-brand-navy to-[#1a2a5e]">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-10 right-20 w-64 h-64 bg-blue-400/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Link href="/blog">
|
||||
<span className="inline-flex items-center space-x-2 text-white/60 hover:text-white transition-colors mb-8 text-sm group">
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span>Retour au blog</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-3 mb-6">
|
||||
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
|
||||
{CATEGORY_LABELS[post.category] ?? post.category}
|
||||
</span>
|
||||
{post.isFeatured && (
|
||||
<span className="px-3 py-1 bg-white/15 text-white text-sm font-medium rounded-full border border-white/20">
|
||||
À la une
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl lg:text-5xl font-bold text-white mb-6 leading-tight tracking-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-white/75 mb-10 leading-relaxed max-w-2xl">{post.excerpt}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-white/55 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{post.authorName}</span>
|
||||
</div>
|
||||
{post.publishedAt && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{readingTime} min de lecture</span>
|
||||
</div>
|
||||
{post.tags.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span>{post.tags.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 64" preserveAspectRatio="none">
|
||||
<path d="M0,32 C360,64 720,0 1080,32 C1260,48 1380,24 1440,32 L1440,64 L0,64 Z" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cover Image */}
|
||||
{post.coverImageUrl && (
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8 -mt-6 relative z-10 mb-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<img
|
||||
src={resolveUrl(post.coverImageUrl)}
|
||||
alt={post.title}
|
||||
className="w-full h-72 lg:h-[420px] object-cover rounded-2xl shadow-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content + Sidebar */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-4xl mx-auto px-6 lg:px-8">
|
||||
<div className="lg:grid lg:grid-cols-[1fr_80px] lg:gap-8 items-start">
|
||||
{/* Article body */}
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="prose prose-lg prose-slate max-w-none
|
||||
prose-headings:font-bold prose-headings:text-brand-navy prose-headings:tracking-tight
|
||||
prose-h2:text-2xl prose-h2:mt-10 prose-h2:mb-4 prose-h2:border-l-4 prose-h2:border-brand-turquoise prose-h2:pl-4
|
||||
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3
|
||||
prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-5
|
||||
prose-a:text-brand-turquoise prose-a:font-medium hover:prose-a:underline
|
||||
prose-strong:text-brand-navy
|
||||
prose-blockquote:not-italic
|
||||
prose-ul:text-gray-700 prose-ol:text-gray-700
|
||||
prose-img:rounded-xl prose-img:shadow-lg"
|
||||
dangerouslySetInnerHTML={{ __html: processedContent }}
|
||||
/>
|
||||
|
||||
{/* Floating share button (desktop) */}
|
||||
<div className="hidden lg:block sticky top-24">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="w-10 h-10 flex items-center justify-center bg-white border border-gray-200 rounded-full shadow-sm text-gray-400 hover:text-brand-turquoise hover:border-brand-turquoise transition-colors"
|
||||
title="Partager l'article"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
|
||||
{post.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1.5 bg-gray-100 text-gray-600 text-sm rounded-full hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-16 rounded-2xl bg-gradient-to-br from-brand-navy to-[#1a2a5e] p-8 lg:p-10 text-center">
|
||||
<BookOpen className="w-8 h-8 text-brand-turquoise mx-auto mb-4" />
|
||||
<h2 className="text-xl lg:text-2xl font-bold text-white mb-3">
|
||||
Prêt à tester Xpeditis ?
|
||||
</h2>
|
||||
<p className="text-white/70 mb-6 max-w-md mx-auto text-sm leading-relaxed">
|
||||
Créez votre compte en 2 minutes et lancez votre premier chiffrage maritime instantané.
|
||||
C'est gratuit et sans engagement.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-brand-turquoise text-white rounded-xl font-semibold hover:bg-brand-turquoise/90 transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5"
|
||||
>
|
||||
Créer mon compte gratuitement
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Back + Share */}
|
||||
<div className="mt-10 flex items-center justify-between">
|
||||
<Link href="/blog">
|
||||
<span className="inline-flex items-center space-x-2 px-5 py-2.5 bg-brand-navy text-white rounded-xl hover:bg-brand-navy/90 transition-all font-medium text-sm group">
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
|
||||
<span>Retour au blog</span>
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="lg:hidden inline-flex items-center space-x-2 px-4 py-2.5 border border-gray-200 rounded-xl text-gray-600 hover:border-brand-turquoise hover:text-brand-turquoise transition-colors text-sm"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span>Partager</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/frontend/app/[locale]/blog/[slug]/page.tsx
Normal file
81
apps/frontend/app/[locale]/blog/[slug]/page.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import type { Metadata } from 'next';
|
||||
import BlogPostContent from './BlogPostContent';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
async function fetchPostMeta(slug: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<{
|
||||
title: string;
|
||||
excerpt: string;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
primaryKeyword?: string;
|
||||
secondaryKeywords?: string[];
|
||||
coverImageUrl?: string;
|
||||
authorName: string;
|
||||
publishedAt?: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; locale: string };
|
||||
}): Promise<Metadata> {
|
||||
const post = await fetchPostMeta(params.slug);
|
||||
|
||||
if (!post) {
|
||||
return { title: 'Blog — Xpeditis' };
|
||||
}
|
||||
|
||||
const title = post.metaTitle || post.title;
|
||||
const description = post.metaDescription || post.excerpt;
|
||||
const keywords = [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const coverImage = post.coverImageUrl
|
||||
? post.coverImageUrl.startsWith('http')
|
||||
? post.coverImageUrl
|
||||
: `${API_BASE_URL}${post.coverImageUrl}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: keywords || undefined,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'article',
|
||||
publishedTime: post.publishedAt,
|
||||
authors: [post.authorName],
|
||||
images: coverImage ? [{ url: coverImage, alt: post.title }] : [],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: coverImage ? [coverImage] : [],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/blog/${params.slug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; locale: string };
|
||||
}) {
|
||||
return <BlogPostContent slug={params.slug} />;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
@ -19,9 +19,16 @@ import {
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
import { getBlogPosts, type BlogPost, type BlogPostCategory } from '@/lib/api/blog';
|
||||
|
||||
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
|
||||
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
function resolveUrl(url: string | null | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('http')) return url;
|
||||
return `${API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
type CategoryKey = 'all' | BlogPostCategory;
|
||||
|
||||
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
|
||||
{ key: 'all', icon: BookOpen },
|
||||
@ -31,20 +38,36 @@ const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
|
||||
{ key: 'news', icon: Globe },
|
||||
];
|
||||
|
||||
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [
|
||||
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] },
|
||||
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] },
|
||||
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] },
|
||||
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] },
|
||||
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] },
|
||||
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] },
|
||||
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] },
|
||||
];
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.6, staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } },
|
||||
};
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function BlogPage() {
|
||||
const t = useTranslations('marketing.blog');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [featuredPost, setFeaturedPost] = useState<BlogPost | null>(null);
|
||||
|
||||
const heroRef = useRef(null);
|
||||
const articlesRef = useRef(null);
|
||||
@ -54,44 +77,50 @@ export default function BlogPage() {
|
||||
const isArticlesInView = useInView(articlesRef, { once: true });
|
||||
const isCategoriesInView = useInView(categoriesRef, { once: true });
|
||||
|
||||
const filteredArticles = ARTICLES.filter((article) => {
|
||||
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
|
||||
const title = t(`articles.${article.key}.title` as any);
|
||||
const excerpt = t(`articles.${article.key}.excerpt` as any);
|
||||
const searchMatch =
|
||||
searchQuery === '' ||
|
||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return categoryMatch && searchMatch;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getBlogPosts({
|
||||
category: selectedCategory !== 'all' ? selectedCategory : undefined,
|
||||
search: searchQuery || undefined,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0, y: 50 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
if (!cancelled) {
|
||||
const featured = res.posts.find(p => p.isFeatured) ?? res.posts[0] ?? null;
|
||||
const rest = res.posts.filter(p => p !== featured);
|
||||
setFeaturedPost(featured);
|
||||
setPosts(rest);
|
||||
setTotal(res.total);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setPosts([]);
|
||||
setFeaturedPost(null);
|
||||
setTotal(0);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5 },
|
||||
},
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedCategory, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<LandingHeader activePage="blog" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -126,7 +155,6 @@ export default function BlogPage() {
|
||||
{t('intro')}
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
||||
@ -139,7 +167,7 @@ export default function BlogPage() {
|
||||
type="text"
|
||||
placeholder={t('searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
@ -147,7 +175,6 @@ export default function BlogPage() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Wave */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
|
||||
<path
|
||||
@ -167,7 +194,7 @@ export default function BlogPage() {
|
||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
{CATEGORIES.map((category) => {
|
||||
{CATEGORIES.map(category => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = selectedCategory === category.key;
|
||||
return (
|
||||
@ -190,6 +217,7 @@ export default function BlogPage() {
|
||||
</section>
|
||||
|
||||
{/* Featured Article */}
|
||||
{!loading && featuredPost && (
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<motion.div
|
||||
@ -198,12 +226,19 @@ export default function BlogPage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Link href="/blog/1">
|
||||
<Link href={`/blog/${featuredPost.slug}`}>
|
||||
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
|
||||
{featuredPost.coverImageUrl ? (
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1/2 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${resolveUrl(featuredPost.coverImageUrl)})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
|
||||
<Anchor className="w-48 h-48 text-white/10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-20 p-8 lg:p-12">
|
||||
<div className="max-w-2xl">
|
||||
@ -212,29 +247,27 @@ export default function BlogPage() {
|
||||
{t('featuredBadge')}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
|
||||
{t('categories.technology')}
|
||||
{t(`categories.${featuredPost.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
|
||||
{t('featured.title')}
|
||||
{featuredPost.title}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p>
|
||||
<p className="text-lg text-white/80 mb-6">{featuredPost.excerpt}</p>
|
||||
|
||||
<div className="flex items-center space-x-6 text-white/60 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>{t('featured.author')}</span>
|
||||
<span>{featuredPost.authorName}</span>
|
||||
</div>
|
||||
{featuredPost.publishedAt && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{t('featured.date')}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t('featured.readTime')}</span>
|
||||
<span>{formatDate(featuredPost.publishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
@ -248,6 +281,7 @@ export default function BlogPage() {
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Articles Grid */}
|
||||
<section ref={articlesRef} className="py-16 bg-gray-50">
|
||||
@ -259,10 +293,26 @@ export default function BlogPage() {
|
||||
className="flex items-center justify-between mb-12"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
|
||||
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span>
|
||||
<span className="text-gray-500">{t('articlesCount', { count: posts.length })}</span>
|
||||
</motion.div>
|
||||
|
||||
{filteredArticles.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="aspect-video bg-gray-200" />
|
||||
<div className="p-6 space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4" />
|
||||
<div className="h-3 bg-gray-200 rounded" />
|
||||
<div className="h-3 bg-gray-200 rounded w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
|
||||
@ -275,30 +325,36 @@ export default function BlogPage() {
|
||||
animate={isArticlesInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{filteredArticles.map((article) => (
|
||||
<motion.div key={article.id} variants={itemVariants}>
|
||||
<Link href={`/blog/${article.id}`}>
|
||||
{posts.map(post => (
|
||||
<motion.div key={post.id} variants={itemVariants}>
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
|
||||
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative">
|
||||
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative overflow-hidden">
|
||||
{post.coverImageUrl ? (
|
||||
<img
|
||||
src={resolveUrl(post.coverImageUrl)}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Ship className="w-16 h-16 text-brand-navy/20" />
|
||||
)}
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
|
||||
{t(`categories.${article.category}`)}
|
||||
{t(`categories.${post.category}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
|
||||
{t(`articles.${article.key}.title` as any)}
|
||||
{post.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">
|
||||
{t(`articles.${article.key}.excerpt` as any)}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{article.tags.map((tag) => (
|
||||
{post.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
|
||||
@ -313,15 +369,14 @@ export default function BlogPage() {
|
||||
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-brand-turquoise" />
|
||||
</div>
|
||||
<span>{t(`articles.${article.key}.author` as any)}</span>
|
||||
<span>{post.authorName}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>{t(`articles.${article.key}.date` as any)}</span>
|
||||
<span className="flex items-center space-x-1">
|
||||
{post.publishedAt && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{t(`articles.${article.key}.readTime` as any)}</span>
|
||||
</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -330,21 +385,6 @@ export default function BlogPage() {
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Load More */}
|
||||
{filteredArticles.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
|
||||
{t('loadMore')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -357,12 +397,8 @@ export default function BlogPage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-white mb-6">
|
||||
{t('newsletter.title')}
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-10">
|
||||
{t('newsletter.body')}
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-white mb-6">{t('newsletter.title')}</h2>
|
||||
<p className="text-xl text-white/80 mb-10">{t('newsletter.body')}</p>
|
||||
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<input
|
||||
type="email"
|
||||
@ -377,9 +413,7 @@ export default function BlogPage() {
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<p className="text-white/50 text-sm mt-4">
|
||||
{t('newsletter.disclaimer')}
|
||||
</p>
|
||||
<p className="text-white/50 text-sm mt-4">{t('newsletter.disclaimer')}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -81,9 +81,7 @@ export default function BookingConfirmPage() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('errorTitle')}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
|
||||
<p className="text-gray-600">{error}</p>
|
||||
</div>
|
||||
|
||||
@ -98,9 +96,7 @@ export default function BookingConfirmPage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
{t('errorContact')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -134,22 +130,14 @@ export default function BookingConfirmPage() {
|
||||
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
||||
{t('successTitle')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-2">
|
||||
{t('successHeadline')}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{t('successBody')}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('successTitle')}</h1>
|
||||
<p className="text-lg text-gray-600 mb-2">{t('successHeadline')}</p>
|
||||
<p className="text-gray-500">{t('successBody')}</p>
|
||||
</div>
|
||||
|
||||
{/* Booking Summary */}
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('summaryTitle')}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
||||
@ -197,14 +185,12 @@ export default function BookingConfirmPage() {
|
||||
<div className="font-bold text-xl text-green-600">
|
||||
{booking.primaryCurrency === 'USD'
|
||||
? `$${booking.priceUSD.toLocaleString()}`
|
||||
: `€${booking.priceEUR.toLocaleString()}`
|
||||
}
|
||||
: `€${booking.priceEUR.toLocaleString()}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{booking.primaryCurrency === 'USD'
|
||||
? `(€${booking.priceEUR.toLocaleString()})`
|
||||
: `($${booking.priceUSD.toLocaleString()})`
|
||||
}
|
||||
: `($${booking.priceUSD.toLocaleString()})`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -222,7 +208,12 @@ export default function BookingConfirmPage() {
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('nextStepsTitle')}
|
||||
</h3>
|
||||
@ -239,10 +230,23 @@ export default function BookingConfirmPage() {
|
||||
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
|
||||
<div className="space-y-2">
|
||||
{booking.documents.map((doc, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-white rounded border border-gray-200">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-white rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>
|
||||
|
||||
@ -89,9 +89,7 @@ export default function BookingRejectPage() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('errorTitle')}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
|
||||
<p className="text-gray-600">{error}</p>
|
||||
</div>
|
||||
|
||||
@ -106,9 +104,7 @@ export default function BookingRejectPage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
{t('errorContact')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -137,21 +133,13 @@ export default function BookingRejectPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
||||
{t('rejectedTitle')}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-2">
|
||||
{t('rejectedHeadline')}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{t('rejectedBody')}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('rejectedTitle')}</h1>
|
||||
<p className="text-lg text-gray-600 mb-2">{t('rejectedHeadline')}</p>
|
||||
<p className="text-gray-500">{t('rejectedBody')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('summaryTitle')}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between py-2 border-b border-gray-200">
|
||||
@ -181,8 +169,7 @@ export default function BookingRejectPage() {
|
||||
<span className="font-semibold text-gray-900">
|
||||
{booking.primaryCurrency === 'USD'
|
||||
? `$${booking.priceUSD.toLocaleString()}`
|
||||
: `€${booking.priceEUR.toLocaleString()}`
|
||||
}
|
||||
: `€${booking.priceEUR.toLocaleString()}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -200,13 +187,16 @@ export default function BookingRejectPage() {
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('infoTitle')}
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
{t('infoBody')}
|
||||
</p>
|
||||
<p className="text-sm text-blue-800">{t('infoBody')}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
@ -259,12 +249,8 @@ export default function BookingRejectPage() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('formTitle')}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{t('formIntro')}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('formTitle')}</h1>
|
||||
<p className="text-gray-600">{t('formIntro')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@ -275,8 +261,18 @@ export default function BookingRejectPage() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700">{t('addReason')}</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
@ -289,18 +285,14 @@ export default function BookingRejectPage() {
|
||||
id="reason"
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder={t('reasonPlaceholder')}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('reasonHint')}
|
||||
</p>
|
||||
<span className="text-xs text-gray-400">
|
||||
{reason.length}/500
|
||||
</span>
|
||||
<p className="text-xs text-gray-500">{t('reasonHint')}</p>
|
||||
<span className="text-xs text-gray-400">{reason.length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -320,16 +312,36 @@ export default function BookingRejectPage() {
|
||||
>
|
||||
{isRejecting ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('submitting')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{t('submit')}
|
||||
</>
|
||||
@ -344,9 +356,7 @@ export default function BookingRejectPage() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-center text-gray-500">
|
||||
{t('helpText')}
|
||||
</p>
|
||||
<p className="mt-6 text-xs text-center text-gray-500">{t('helpText')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -64,15 +64,76 @@ type JobRecord = {
|
||||
};
|
||||
|
||||
const JOBS: JobRecord[] = [
|
||||
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code },
|
||||
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code },
|
||||
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart },
|
||||
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone },
|
||||
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones },
|
||||
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart },
|
||||
{
|
||||
id: 1,
|
||||
key: 'frontend',
|
||||
department: 'Engineering',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '65K - 85K €',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
key: 'backend',
|
||||
department: 'Engineering',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '55K - 75K €',
|
||||
icon: Code,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
key: 'pm',
|
||||
department: 'Product',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '60K - 80K €',
|
||||
icon: LineChart,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
key: 'ae',
|
||||
department: 'Sales',
|
||||
location: 'Rotterdam',
|
||||
type: 'CDI',
|
||||
remote: false,
|
||||
salary: '50K - 70K € + variable',
|
||||
icon: Megaphone,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
key: 'csm',
|
||||
department: 'Customer Success',
|
||||
location: 'Paris',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '45K - 60K €',
|
||||
icon: Headphones,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
key: 'data',
|
||||
department: 'Data',
|
||||
location: 'Hambourg',
|
||||
type: 'CDI',
|
||||
remote: true,
|
||||
salary: '50K - 65K €',
|
||||
icon: LineChart,
|
||||
},
|
||||
];
|
||||
|
||||
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data'];
|
||||
const DEPARTMENT_VALUES: DepartmentValue[] = [
|
||||
'all',
|
||||
'Engineering',
|
||||
'Product',
|
||||
'Sales',
|
||||
'Customer Success',
|
||||
'Data',
|
||||
];
|
||||
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
|
||||
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
|
||||
|
||||
@ -92,7 +153,7 @@ export default function CareersPage() {
|
||||
const isJobsInView = useInView(jobsRef, { once: true });
|
||||
const isCultureInView = useInView(cultureRef, { once: true });
|
||||
|
||||
const filteredJobs = JOBS.filter((job) => {
|
||||
const filteredJobs = JOBS.filter(job => {
|
||||
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
|
||||
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
|
||||
return departmentMatch && locationMatch;
|
||||
@ -124,7 +185,10 @@ export default function CareersPage() {
|
||||
<LandingHeader activePage="careers" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -221,9 +285,7 @@ export default function CareersPage() {
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
{t('benefitsTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('benefitsSubtitle')}
|
||||
</p>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -232,7 +294,7 @@ export default function CareersPage() {
|
||||
animate={isBenefitsInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{BENEFITS.map((benefit) => {
|
||||
{BENEFITS.map(benefit => {
|
||||
const IconComponent = benefit.icon;
|
||||
return (
|
||||
<motion.div
|
||||
@ -244,7 +306,9 @@ export default function CareersPage() {
|
||||
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<IconComponent className="w-7 h-7 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-2">
|
||||
{t(`benefits.${benefit.key}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
|
||||
</motion.div>
|
||||
);
|
||||
@ -254,7 +318,10 @@ export default function CareersPage() {
|
||||
</section>
|
||||
|
||||
{/* Culture Section */}
|
||||
<section ref={cultureRef} className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
|
||||
<section
|
||||
ref={cultureRef}
|
||||
className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<motion.div
|
||||
@ -265,9 +332,7 @@ export default function CareersPage() {
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
{t('cultureTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-8">
|
||||
{t('cultureBody')}
|
||||
</p>
|
||||
<p className="text-xl text-white/80 mb-8">{t('cultureBody')}</p>
|
||||
<ul className="space-y-4">
|
||||
{CULTURE_ITEMS.map((itemKey, index) => (
|
||||
<motion.li
|
||||
@ -292,7 +357,7 @@ export default function CareersPage() {
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-white/10 rounded-2xl flex items-center justify-center"
|
||||
@ -317,9 +382,7 @@ export default function CareersPage() {
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||
{t('jobsTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('jobsSubtitle')}
|
||||
</p>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters */}
|
||||
@ -332,12 +395,14 @@ export default function CareersPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedDepartment}
|
||||
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)}
|
||||
onChange={e => setSelectedDepartment(e.target.value as DepartmentValue)}
|
||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||
>
|
||||
{DEPARTMENT_VALUES.map((value) => (
|
||||
{DEPARTMENT_VALUES.map(value => (
|
||||
<option key={value} value={value}>
|
||||
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)}
|
||||
{value === 'all'
|
||||
? t('filters.allDepartments')
|
||||
: t(`departments.${value}` as any)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -346,10 +411,10 @@ export default function CareersPage() {
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLocation}
|
||||
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)}
|
||||
onChange={e => setSelectedLocation(e.target.value as LocationValue)}
|
||||
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
|
||||
>
|
||||
{LOCATION_VALUES.map((value) => (
|
||||
{LOCATION_VALUES.map(value => (
|
||||
<option key={value} value={value}>
|
||||
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)}
|
||||
</option>
|
||||
@ -373,7 +438,7 @@ export default function CareersPage() {
|
||||
<p className="text-gray-500">{t('noJobs.body')}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredJobs.map((job) => {
|
||||
filteredJobs.map(job => {
|
||||
const IconComponent = job.icon;
|
||||
const isExpanded = expandedJob === job.id;
|
||||
|
||||
@ -393,7 +458,9 @@ export default function CareersPage() {
|
||||
<IconComponent className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-brand-navy">{t(`jobs.${job.key}.title`)}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy">
|
||||
{t(`jobs.${job.key}.title`)}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
|
||||
<span className="flex items-center space-x-1">
|
||||
<Building2 className="w-4 h-4" />
|
||||
@ -441,10 +508,15 @@ export default function CareersPage() {
|
||||
>
|
||||
<div className="p-6 bg-gray-50">
|
||||
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
|
||||
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4>
|
||||
<h4 className="font-bold text-brand-navy mb-3">
|
||||
{t('jobCard.profile')}
|
||||
</h4>
|
||||
<ul className="space-y-2 mb-6">
|
||||
{JOB_REQ_KEYS.map((reqKey) => (
|
||||
<li key={reqKey} className="flex items-start space-x-2 text-gray-600">
|
||||
{JOB_REQ_KEYS.map(reqKey => (
|
||||
<li
|
||||
key={reqKey}
|
||||
className="flex items-start space-x-2 text-gray-600"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
|
||||
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
|
||||
</li>
|
||||
@ -483,12 +555,8 @@ export default function CareersPage() {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-4xl font-bold text-brand-navy mb-6">
|
||||
{t('cta.title')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-10">
|
||||
{t('cta.body')}
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold text-brand-navy mb-6">{t('cta.title')}</h2>
|
||||
<p className="text-xl text-gray-600 mb-10">{t('cta.body')}</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
|
||||
|
||||
@ -55,7 +55,10 @@ export default function CarrierAcceptPage() {
|
||||
errorMessage = t('common.bookingAlreadyAccepted');
|
||||
} else if (errorMessage.includes('status REJECTED')) {
|
||||
errorMessage = t('common.bookingAlreadyRejected');
|
||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
||||
} else if (
|
||||
errorMessage.includes('not found') ||
|
||||
errorMessage.includes('Booking not found')
|
||||
) {
|
||||
errorMessage = t('common.bookingNotFound');
|
||||
}
|
||||
|
||||
@ -65,7 +68,7 @@ export default function CarrierAcceptPage() {
|
||||
setLoading(false);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
router.push('/');
|
||||
@ -91,12 +94,8 @@ export default function CarrierAcceptPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
{t('accept.loadingTitle')}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{t('accept.loadingMessage')}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('accept.loadingTitle')}</h1>
|
||||
<p className="text-gray-600">{t('accept.loadingMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -124,22 +123,14 @@ export default function CarrierAcceptPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{t('accept.thanksTitle')}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('accept.thanksTitle')}</h1>
|
||||
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
||||
<p className="text-green-800 font-medium text-lg mb-2">
|
||||
{t('accept.successHeadline')}
|
||||
</p>
|
||||
<p className="text-green-700 text-sm">
|
||||
{t('accept.successBody')}
|
||||
</p>
|
||||
<p className="text-green-800 font-medium text-lg mb-2">{t('accept.successHeadline')}</p>
|
||||
<p className="text-green-700 text-sm">{t('accept.successBody')}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
{t('common.redirecting', { countdown })}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
|
||||
@ -189,7 +189,10 @@ export default function CarrierDocumentsPage() {
|
||||
throw new Error(t('notAcceptedYet'));
|
||||
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
||||
throw new Error(t('bookingNotFound'));
|
||||
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
||||
} else if (
|
||||
errorMessage.includes('Mot de passe requis') ||
|
||||
errorMessage.includes('required')
|
||||
) {
|
||||
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
||||
setLoading(false);
|
||||
return;
|
||||
@ -336,12 +339,11 @@ export default function CarrierDocumentsPage() {
|
||||
<Lock className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t('password.intro')}
|
||||
</p>
|
||||
<p className="text-gray-600">{t('password.intro')}</p>
|
||||
{requirements.bookingNumber && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
||||
{t('password.bookingLabel')}{' '}
|
||||
<span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -467,7 +469,9 @@ export default function CarrierDocumentsPage() {
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
|
||||
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{t('summary.transitDays', { count: booking.transitDays })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
||||
@ -504,9 +508,7 @@ export default function CarrierDocumentsPage() {
|
||||
<div className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">{t('list.empty')}</p>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
{t('list.emptyHint')}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
@ -552,9 +554,7 @@ export default function CarrierDocumentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<p className="mt-6 text-center text-sm text-gray-500">
|
||||
{t('footerNote')}
|
||||
</p>
|
||||
<p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@ -55,7 +55,10 @@ export default function CarrierRejectPage() {
|
||||
errorMessage = t('common.bookingAlreadyRejected');
|
||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
||||
errorMessage = t('common.bookingAlreadyAccepted');
|
||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
||||
} else if (
|
||||
errorMessage.includes('not found') ||
|
||||
errorMessage.includes('Booking not found')
|
||||
) {
|
||||
errorMessage = t('common.bookingNotFound');
|
||||
}
|
||||
|
||||
@ -65,7 +68,7 @@ export default function CarrierRejectPage() {
|
||||
setLoading(false);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
setCountdown(prev => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
router.push('/');
|
||||
@ -91,12 +94,8 @@ export default function CarrierRejectPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
{t('reject.loadingTitle')}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{t('reject.loadingMessage')}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('reject.loadingTitle')}</h1>
|
||||
<p className="text-gray-600">{t('reject.loadingMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -124,22 +123,14 @@ export default function CarrierRejectPage() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{t('reject.thanksTitle')}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('reject.thanksTitle')}</h1>
|
||||
|
||||
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
|
||||
<p className="text-orange-800 font-medium text-lg mb-2">
|
||||
{t('reject.successHeadline')}
|
||||
</p>
|
||||
<p className="text-orange-700 text-sm">
|
||||
{t('reject.successBody')}
|
||||
</p>
|
||||
<p className="text-orange-800 font-medium text-lg mb-2">{t('reject.successHeadline')}</p>
|
||||
<p className="text-orange-700 text-sm">{t('reject.successBody')}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
{t('common.redirecting', { countdown })}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
|
||||
@ -77,7 +77,10 @@ export default function CompliancePage() {
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -148,9 +151,7 @@ export default function CompliancePage() {
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
{t('rightsTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('rightsSubtitle')}
|
||||
</p>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -159,7 +160,7 @@ export default function CompliancePage() {
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
{RIGHTS.map((right) => {
|
||||
{RIGHTS.map(right => {
|
||||
const IconComponent = right.icon;
|
||||
return (
|
||||
<motion.div
|
||||
@ -171,7 +172,9 @@ export default function CompliancePage() {
|
||||
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<IconComponent className="w-8 h-8 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||
{t(`rights.${right.key}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
|
||||
</motion.div>
|
||||
);
|
||||
@ -185,9 +188,7 @@ export default function CompliancePage() {
|
||||
transition={{ duration: 0.8, delay: 0.4 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('rightsCta.text')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-4">{t('rightsCta.text')}</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<Link
|
||||
href="/login"
|
||||
@ -219,9 +220,7 @@ export default function CompliancePage() {
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
{t('principlesTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('principlesSubtitle')}
|
||||
</p>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
@ -239,8 +238,12 @@ export default function CompliancePage() {
|
||||
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<IconComponent className="w-6 h-6 text-brand-green" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3>
|
||||
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-2">
|
||||
{t(`principles.${principle.key}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{t(`principles.${principle.key}.description`)}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
@ -261,9 +264,7 @@ export default function CompliancePage() {
|
||||
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
|
||||
{t('measuresTitle')}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{t('measuresSubtitle')}
|
||||
</p>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
@ -278,7 +279,7 @@ export default function CompliancePage() {
|
||||
>
|
||||
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3>
|
||||
<ul className="space-y-4">
|
||||
{MEASURE_ITEMS.map((itemKey) => (
|
||||
{MEASURE_ITEMS.map(itemKey => (
|
||||
<li key={itemKey} className="flex items-center space-x-3 text-white/80">
|
||||
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
|
||||
<span>{t(`measures.${key}.${itemKey}` as any)}</span>
|
||||
@ -306,14 +307,10 @@ export default function CompliancePage() {
|
||||
<FileText className="w-6 h-6 text-brand-turquoise" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">
|
||||
{t('register.title')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{t('register.body')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('register.title')}</h3>
|
||||
<p className="text-gray-600 mb-6">{t('register.body')}</p>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
{REGISTER_ITEMS.map((itemKey) => (
|
||||
{REGISTER_ITEMS.map(itemKey => (
|
||||
<li key={itemKey} className="flex items-center space-x-3">
|
||||
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
|
||||
<span>{t(`register.${itemKey}` as any)}</span>
|
||||
@ -337,12 +334,8 @@ export default function CompliancePage() {
|
||||
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
|
||||
>
|
||||
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-white mb-4">
|
||||
{t('dpo.title')}
|
||||
</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
||||
{t('dpo.body')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-white mb-4">{t('dpo.title')}</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">{t('dpo.body')}</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
<a
|
||||
href="mailto:dpo@xpeditis.com"
|
||||
|
||||
@ -34,7 +34,15 @@ const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
|
||||
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
|
||||
];
|
||||
|
||||
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other'];
|
||||
const SUBJECTS: SubjectKey[] = [
|
||||
'demo',
|
||||
'pricing',
|
||||
'partnership',
|
||||
'support',
|
||||
'press',
|
||||
'careers',
|
||||
'other',
|
||||
];
|
||||
|
||||
export default function ContactPage() {
|
||||
const t = useTranslations('marketing.contact');
|
||||
@ -89,7 +97,7 @@ export default function ContactPage() {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}));
|
||||
@ -121,7 +129,10 @@ export default function ContactPage() {
|
||||
<LandingHeader activePage="contact" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -178,7 +189,7 @@ export default function ContactPage() {
|
||||
className="max-w-7xl mx-auto px-6 lg:px-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{METHODS.map((method) => {
|
||||
{METHODS.map(method => {
|
||||
const IconComponent = method.icon;
|
||||
return (
|
||||
<motion.div
|
||||
@ -191,9 +202,15 @@ export default function ContactPage() {
|
||||
>
|
||||
<IconComponent className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3>
|
||||
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p>
|
||||
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p>
|
||||
<h3 className="text-lg font-bold text-brand-navy mb-1">
|
||||
{t(`methods.${method.key}.title`)}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm mb-2">
|
||||
{t(`methods.${method.key}.description`)}
|
||||
</p>
|
||||
<p className="text-brand-turquoise font-medium">
|
||||
{t(`methods.${method.key}.value`)}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
@ -212,9 +229,7 @@ export default function ContactPage() {
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
{t('form.description')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-8">{t('form.description')}</p>
|
||||
|
||||
{isSubmitted ? (
|
||||
<motion.div
|
||||
@ -225,10 +240,10 @@ export default function ContactPage() {
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3>
|
||||
<p className="text-green-700 mb-6">
|
||||
{t('form.successBody')}
|
||||
</p>
|
||||
<h3 className="text-2xl font-bold text-green-800 mb-2">
|
||||
{t('form.successTitle')}
|
||||
</h3>
|
||||
<p className="text-green-700 mb-6">{t('form.successBody')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
@ -251,7 +266,10 @@ export default function ContactPage() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="firstName"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.firstName')} *
|
||||
</label>
|
||||
<input
|
||||
@ -266,7 +284,10 @@ export default function ContactPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="lastName"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.lastName')} *
|
||||
</label>
|
||||
<input
|
||||
@ -284,7 +305,10 @@ export default function ContactPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.email')} *
|
||||
</label>
|
||||
<input
|
||||
@ -299,7 +323,10 @@ export default function ContactPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="phone"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.phone')}
|
||||
</label>
|
||||
<input
|
||||
@ -315,7 +342,10 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="company"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.company')}
|
||||
</label>
|
||||
<input
|
||||
@ -330,7 +360,10 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.subject')} *
|
||||
</label>
|
||||
<select
|
||||
@ -342,7 +375,7 @@ export default function ContactPage() {
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="">{t('subjects.placeholder')}</option>
|
||||
{SUBJECTS.map((key) => (
|
||||
{SUBJECTS.map(key => (
|
||||
<option key={key} value={key}>
|
||||
{t(`subjects.${key}`)}
|
||||
</option>
|
||||
@ -351,7 +384,10 @@ export default function ContactPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{t('form.message')} *
|
||||
</label>
|
||||
<textarea
|
||||
@ -400,9 +436,7 @@ export default function ContactPage() {
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
|
||||
<p className="text-gray-600 mb-8">
|
||||
{t('office.subtitle')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-8">{t('office.subtitle')}</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
|
||||
@ -427,13 +461,19 @@ export default function ContactPage() {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
|
||||
<a
|
||||
href={`tel:${t('office.phone').replace(/\s/g, '')}`}
|
||||
className="hover:text-brand-turquoise"
|
||||
>
|
||||
{t('office.phone')}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise">
|
||||
<a
|
||||
href={`mailto:${t('office.email')}`}
|
||||
className="hover:text-brand-turquoise"
|
||||
>
|
||||
{t('office.email')}
|
||||
</a>
|
||||
</div>
|
||||
@ -463,9 +503,7 @@ export default function ContactPage() {
|
||||
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
{t('hours.supportNote')}
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-gray-500">{t('hours.supportNote')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -511,7 +549,9 @@ export default function ContactPage() {
|
||||
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{t('afterSubmit.commitmentTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-white/80 leading-relaxed">
|
||||
{t('afterSubmit.commitmentBody1')}
|
||||
@ -532,11 +572,16 @@ export default function ContactPage() {
|
||||
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-5 h-5 text-brand-green" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{t('afterSubmit.securityTitle')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-white/80 leading-relaxed">
|
||||
{t('afterSubmit.securityBody1')}
|
||||
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="text-brand-turquoise font-semibold hover:underline"
|
||||
>
|
||||
{t('afterSubmit.privacyLink')}
|
||||
</Link>
|
||||
{t('afterSubmit.securityBody2')}
|
||||
@ -577,10 +622,14 @@ export default function ContactPage() {
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
||||
<Zap className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||
{t('quickAccess.pricingTitle')}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||
{t('quickAccess.pricingBody1')}
|
||||
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span>
|
||||
<span className="font-semibold text-brand-navy">
|
||||
{t('quickAccess.pricingHighlight')}
|
||||
</span>
|
||||
{t('quickAccess.pricingBody2')}
|
||||
</p>
|
||||
<Link
|
||||
@ -603,10 +652,14 @@ export default function ContactPage() {
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
|
||||
<BookOpen className="w-7 h-7 text-brand-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3>
|
||||
<h3 className="text-xl font-bold text-brand-navy mb-3">
|
||||
{t('quickAccess.wikiTitle')}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
|
||||
{t('quickAccess.wikiBody1')}
|
||||
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span>
|
||||
<span className="font-semibold text-brand-navy">
|
||||
{t('quickAccess.wikiHighlight')}
|
||||
</span>
|
||||
{t('quickAccess.wikiBody2')}
|
||||
</p>
|
||||
<Link
|
||||
|
||||
@ -3,7 +3,16 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Cookie,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Target,
|
||||
Shield,
|
||||
ToggleLeft,
|
||||
Mail,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
||||
|
||||
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
|
||||
@ -99,7 +108,10 @@ export default function CookiesPage() {
|
||||
<LandingHeader />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
|
||||
@ -184,7 +196,7 @@ export default function CookiesPage() {
|
||||
animate={isContentInView ? 'visible' : 'hidden'}
|
||||
className="space-y-8"
|
||||
>
|
||||
{COOKIE_TYPES.map((type) => {
|
||||
{COOKIE_TYPES.map(type => {
|
||||
const IconComponent = type.icon;
|
||||
return (
|
||||
<motion.div
|
||||
@ -234,9 +246,11 @@ export default function CookiesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{type.cookies.map((cookie) => (
|
||||
{type.cookies.map(cookie => (
|
||||
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
|
||||
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
|
||||
<td className="py-3 px-4 font-mono text-brand-turquoise">
|
||||
{cookie.name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-gray-600">
|
||||
{t(`purposes.${cookie.purposeKey}` as any)}
|
||||
</td>
|
||||
|
||||
942
apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
Normal file
942
apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx
Normal file
@ -0,0 +1,942 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
getAllBlogPosts,
|
||||
createBlogPost,
|
||||
updateBlogPost,
|
||||
deleteBlogPost,
|
||||
type CreateBlogPostRequest,
|
||||
type UpdateBlogPostRequest,
|
||||
} from '@/lib/api/admin';
|
||||
import { upload } from '@/lib/api/client';
|
||||
import type { BlogPost, BlogPostCategory, BlogPostStatus } from '@/lib/api/blog';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { RichTextEditor } from '@/components/blog/RichTextEditor';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Star,
|
||||
StarOff,
|
||||
ExternalLink,
|
||||
ImageIcon,
|
||||
X,
|
||||
Loader2,
|
||||
Search,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
||||
|
||||
function normalizeImageUrl(url: string | null | undefined): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http')) return url;
|
||||
return `${API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
function normalizeContentUrls(html: string): string {
|
||||
return html.replace(/src="(\/api\/v1\/blog\/images\/[^"]+)"/g, `src="${API_BASE_URL}$1"`);
|
||||
}
|
||||
|
||||
const CATEGORIES: { value: BlogPostCategory; label: string }[] = [
|
||||
{ value: 'industry', label: 'Industrie' },
|
||||
{ value: 'technology', label: 'Technologie' },
|
||||
{ value: 'guides', label: 'Guides' },
|
||||
{ value: 'news', label: 'Actualités' },
|
||||
];
|
||||
|
||||
const STATUS_LABELS: Record<BlogPostStatus, string> = {
|
||||
draft: 'Brouillon',
|
||||
scheduled: 'Planifié',
|
||||
published: 'Publié',
|
||||
archived: 'Archivé',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<BlogPostStatus, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
scheduled: 'bg-blue-100 text-blue-800',
|
||||
published: 'bg-green-100 text-green-800',
|
||||
archived: 'bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
interface FormData extends CreateBlogPostRequest {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
primaryKeyword: string;
|
||||
secondaryKeywordsInput: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormData = {
|
||||
title: '',
|
||||
slug: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
coverImageUrl: '',
|
||||
category: 'industry',
|
||||
tags: [],
|
||||
authorName: '',
|
||||
metaTitle: '',
|
||||
metaDescription: '',
|
||||
primaryKeyword: '',
|
||||
secondaryKeywordsInput: '',
|
||||
};
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
function toLocalDatetimeValue(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// SEO preview snippet
|
||||
function SeoPreview({
|
||||
metaTitle,
|
||||
metaDescription,
|
||||
slug,
|
||||
}: {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
slug: string;
|
||||
}) {
|
||||
const displayTitle = metaTitle || "Titre de l'article";
|
||||
const displayDesc = metaDescription || "Description de l'article...";
|
||||
const displayUrl = `xpeditis.com/blog/${slug || 'votre-slug'}`;
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 text-sm">
|
||||
<p className="text-xs text-gray-400 mb-2 font-medium uppercase tracking-wide">
|
||||
Aperçu Google
|
||||
</p>
|
||||
<p className="text-blue-700 text-base font-medium truncate leading-tight">{displayTitle}</p>
|
||||
<p className="text-green-700 text-xs mt-0.5 truncate">{displayUrl}</p>
|
||||
<p className="text-gray-600 text-xs mt-1 line-clamp-2 leading-relaxed">{displayDesc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBlogPage() {
|
||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedPost, setSelectedPost] = useState<BlogPost | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [editStatus, setEditStatus] = useState<BlogPostStatus>('draft');
|
||||
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [seoOpen, setSeoOpen] = useState(false);
|
||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
const fetchPosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getAllBlogPosts();
|
||||
setPosts(res.posts);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Erreur lors du chargement des articles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => {
|
||||
const tags = tagsInput
|
||||
? tagsInput
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const secondaryKeywords = formData.secondaryKeywordsInput
|
||||
? formData.secondaryKeywordsInput
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
title: formData.title,
|
||||
slug: formData.slug,
|
||||
excerpt: formData.excerpt,
|
||||
content: formData.content,
|
||||
coverImageUrl: formData.coverImageUrl || undefined,
|
||||
category: formData.category,
|
||||
tags,
|
||||
authorName: formData.authorName,
|
||||
metaTitle: formData.metaTitle || undefined,
|
||||
metaDescription: formData.metaDescription || undefined,
|
||||
primaryKeyword: formData.primaryKeyword || undefined,
|
||||
secondaryKeywords,
|
||||
scheduledAt: scheduledAt || undefined,
|
||||
...(withStatus ? { status: withStatus } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent, publishNow?: boolean) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = buildPayload(publishNow ? 'published' : undefined);
|
||||
if (publishNow) payload.scheduledAt = undefined;
|
||||
await createBlogPost(payload);
|
||||
await fetchPosts();
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erreur lors de la création');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedPost) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: UpdateBlogPostRequest = {
|
||||
...buildPayload(editStatus),
|
||||
};
|
||||
await updateBlogPost(selectedPost.id, data);
|
||||
await fetchPosts();
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erreur lors de la mise à jour');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedPost) return;
|
||||
try {
|
||||
await deleteBlogPost(selectedPost.id);
|
||||
await fetchPosts();
|
||||
setShowDeleteConfirm(false);
|
||||
setSelectedPost(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erreur lors de la suppression');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (post: BlogPost) => {
|
||||
const nextStatus: BlogPostStatus = post.status === 'published' ? 'draft' : 'published';
|
||||
try {
|
||||
await updateBlogPost(post.id, { status: nextStatus });
|
||||
await fetchPosts();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erreur lors du changement de statut');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFeatured = async (post: BlogPost) => {
|
||||
try {
|
||||
await updateBlogPost(post.id, { isFeatured: !post.isFeatured });
|
||||
await fetchPosts();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Erreur lors du changement');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image trop volumineuse (max 5 Mo)');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingCover(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
const result = await upload<{ url: string; filename: string }>(
|
||||
'/api/v1/admin/blog/images',
|
||||
fd
|
||||
);
|
||||
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
|
||||
setFormData(prev => ({ ...prev, coverImageUrl: coverUrl }));
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Erreur lors de l'upload");
|
||||
} finally {
|
||||
setUploadingCover(false);
|
||||
if (coverInputRef.current) coverInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setFormData(EMPTY_FORM);
|
||||
setTagsInput('');
|
||||
setScheduledAt('');
|
||||
setEditStatus('draft');
|
||||
setSelectedPost(null);
|
||||
setSeoOpen(false);
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (post: BlogPost) => {
|
||||
setSelectedPost(post);
|
||||
setFormData({
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
content: normalizeContentUrls(post.content),
|
||||
coverImageUrl: normalizeImageUrl(post.coverImageUrl),
|
||||
category: post.category,
|
||||
tags: post.tags,
|
||||
authorName: post.authorName,
|
||||
metaTitle: post.metaTitle ?? '',
|
||||
metaDescription: post.metaDescription ?? '',
|
||||
primaryKeyword: post.primaryKeyword ?? '',
|
||||
secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '),
|
||||
});
|
||||
setTagsInput(post.tags.join(', '));
|
||||
setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : '');
|
||||
setEditStatus(post.status);
|
||||
setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword));
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedPost(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
setTagsInput('');
|
||||
setScheduledAt('');
|
||||
setSeoOpen(false);
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug:
|
||||
prev.slug === generateSlug(prev.title) || prev.slug === ''
|
||||
? generateSlug(title)
|
||||
: prev.slug,
|
||||
metaTitle: prev.metaTitle === prev.title || prev.metaTitle === '' ? title : prev.metaTitle,
|
||||
}));
|
||||
};
|
||||
|
||||
const metaTitleLen = formData.metaTitle.length;
|
||||
const metaDescLen = formData.metaDescription.length;
|
||||
const metaTitleColor =
|
||||
metaTitleLen === 0
|
||||
? 'text-gray-400'
|
||||
: metaTitleLen <= 60
|
||||
? 'text-green-600'
|
||||
: 'text-red-500';
|
||||
const metaDescColor =
|
||||
metaDescLen === 0
|
||||
? 'text-gray-400'
|
||||
: metaDescLen <= 160
|
||||
? 'text-green-600'
|
||||
: 'text-red-500';
|
||||
|
||||
const isOpen = showCreateModal || showEditModal;
|
||||
const isScheduleMode = !!scheduledAt;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<PageHeader
|
||||
title="Gestion du Blog"
|
||||
description="Créez, modifiez et publiez les articles du blog"
|
||||
actions={
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nouvel article</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden mt-6">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Article
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Catégorie
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Auteur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{posts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
Aucun article. Créez votre premier article !
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
posts.map(post => (
|
||||
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{post.coverImageUrl && (
|
||||
<img
|
||||
src={post.coverImageUrl}
|
||||
alt=""
|
||||
className="w-12 h-12 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{post.isFeatured && (
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900 line-clamp-1">
|
||||
{post.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-0.5">{post.slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{CATEGORIES.find(c => c.value === post.category)?.label ?? post.category}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium w-fit ${STATUS_COLORS[post.status]}`}
|
||||
>
|
||||
{STATUS_LABELS[post.status]}
|
||||
</span>
|
||||
{post.status === 'scheduled' && post.publishedAt && (
|
||||
<span className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(post.publishedAt).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{post.authorName}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{post.publishedAt
|
||||
? new Date(post.publishedAt).toLocaleDateString('fr-FR')
|
||||
: new Date(post.createdAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end space-x-1">
|
||||
{(post.status === 'published' || post.status === 'scheduled') && (
|
||||
<Link href={`/blog/${post.slug}`} target="_blank">
|
||||
<button
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Voir"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleToggleFeatured(post)}
|
||||
className="p-1.5 text-gray-400 hover:text-yellow-500 transition-colors"
|
||||
title={post.isFeatured ? 'Retirer de la une' : 'Mettre à la une'}
|
||||
>
|
||||
{post.isFeatured ? (
|
||||
<StarOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Star className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(post)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors"
|
||||
title={post.status === 'published' ? 'Dépublier' : 'Publier'}
|
||||
>
|
||||
{post.status === 'published' ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEdit(post)}
|
||||
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPost(post);
|
||||
setShowDeleteConfirm(true);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create / Edit Modal — Full-screen overlay */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{showEditModal && (
|
||||
<select
|
||||
value={editStatus}
|
||||
onChange={e => setEditStatus(e.target.value as BlogPostStatus)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="draft">Brouillon</option>
|
||||
<option value="scheduled">Planifié</option>
|
||||
<option value="published">Publié</option>
|
||||
<option value="archived">Archivé</option>
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form
|
||||
onSubmit={showCreateModal ? handleCreate : handleUpdate}
|
||||
className="max-w-6xl mx-auto px-6 py-8 grid grid-cols-1 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{/* Main editor — 2/3 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.title}
|
||||
onChange={e => handleTitleChange(e.target.value)}
|
||||
className="w-full text-3xl font-bold text-gray-900 border-0 border-b-2 border-gray-200 focus:border-blue-500 focus:outline-none pb-2 placeholder-gray-300 bg-transparent"
|
||||
placeholder="Titre de l'article..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Extrait *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={2}
|
||||
value={formData.excerpt}
|
||||
onChange={e => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm resize-none"
|
||||
placeholder="Courte description visible dans la liste des articles..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rich Text Editor */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Contenu *</label>
|
||||
<RichTextEditor
|
||||
content={formData.content}
|
||||
onChange={html => setFormData(prev => ({ ...prev, content: html }))}
|
||||
placeholder="Commencez à écrire votre article..."
|
||||
minHeight={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Panel */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSeoOpen(o => !o)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-4 h-4 text-gray-500" />
|
||||
<span className="font-semibold text-gray-900 text-sm">
|
||||
SEO & Référencement
|
||||
</span>
|
||||
{(formData.metaTitle || formData.metaDescription || formData.primaryKeyword) && (
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 inline-block" />
|
||||
)}
|
||||
</div>
|
||||
{seoOpen ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{seoOpen && (
|
||||
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
|
||||
<div className="mt-4">
|
||||
<SeoPreview
|
||||
metaTitle={formData.metaTitle}
|
||||
metaDescription={formData.metaDescription}
|
||||
slug={formData.slug}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Meta Titre
|
||||
</label>
|
||||
<span className={`text-xs ${metaTitleColor}`}>
|
||||
{metaTitleLen}/60
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.metaTitle}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, metaTitle: e.target.value }))
|
||||
}
|
||||
maxLength={255}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Titre affiché dans Google (50-60 car. idéal)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Meta Description
|
||||
</label>
|
||||
<span className={`text-xs ${metaDescColor}`}>
|
||||
{metaDescLen}/160
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.metaDescription}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, metaDescription: e.target.value }))
|
||||
}
|
||||
maxLength={500}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
|
||||
placeholder="Description affichée dans Google (150-160 car. idéal)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mot-clé principal
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.primaryKeyword}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, primaryKeyword: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="ex: devis maritime instantané"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Mots-clés secondaires{' '}
|
||||
<span className="text-gray-400 font-normal">(virgule)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.secondaryKeywordsInput}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
secondaryKeywordsInput: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="ex: fret maritime, suivi de marchandise, Click&Ship"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar — 1/3 */}
|
||||
<div className="space-y-5">
|
||||
{/* Publication */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Publication</h3>
|
||||
|
||||
{/* Scheduled date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 text-gray-400" />
|
||||
Date de publication planifiée
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={e => setScheduledAt(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
{scheduledAt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScheduledAt('')}
|
||||
className="text-xs text-gray-400 hover:text-red-500 mt-1 underline"
|
||||
>
|
||||
Effacer la date
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{isScheduleMode
|
||||
? 'Le post sera publié automatiquement à cette date.'
|
||||
: 'Laisser vide pour publier manuellement.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreateModal ? (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={e => handleCreate(e, true)}
|
||||
className="w-full py-2.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Publier maintenant
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{saving ? 'Enregistrement...' : 'Enregistrer'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Image de couverture</h3>
|
||||
{formData.coverImageUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.coverImageUrl}
|
||||
alt="Couverture"
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData(prev => ({ ...prev, coverImageUrl: '' }))}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md text-gray-600 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverInputRef.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="w-full h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center gap-2 text-gray-400 hover:border-blue-400 hover:text-blue-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploadingCover ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="w-6 h-6" />
|
||||
<span className="text-sm">Cliquer pour uploader</span>
|
||||
<span className="text-xs">JPG, PNG, WebP — max 5 Mo</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={coverInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleCoverUpload}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="url"
|
||||
value={formData.coverImageUrl ?? ''}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, coverImageUrl: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Ou coller une URL..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Métadonnées</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={e => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono"
|
||||
placeholder="mon-article"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Catégorie *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.category}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
category: e.target.value as BlogPostCategory,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Auteur *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.authorName}
|
||||
onChange={e => setFormData(prev => ({ ...prev, authorName: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Nom de l'auteur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tags <span className="text-gray-400 font-normal">(virgule)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsInput}
|
||||
onChange={e => setTagsInput(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Maritime, LCL, Export"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm */}
|
||||
{showDeleteConfirm && selectedPost && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-2">Supprimer l'article</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Êtes-vous sûr de vouloir supprimer <strong>« {selectedPost.title} »</strong>{' '}
|
||||
? Cette action est irréversible.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setSelectedPost(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-6 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -100,7 +100,14 @@ export default function AdminBookingsPage() {
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const key = status.toUpperCase();
|
||||
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'];
|
||||
const allowed = [
|
||||
'PENDING_PAYMENT',
|
||||
'PENDING_BANK_TRANSFER',
|
||||
'PENDING',
|
||||
'ACCEPTED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
];
|
||||
if (allowed.includes(key)) {
|
||||
return t(`status.${key}` as any);
|
||||
}
|
||||
@ -143,9 +150,7 @@ export default function AdminBookingsPage() {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
@ -155,13 +160,17 @@ export default function AdminBookingsPage() {
|
||||
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
||||
<div className="text-xs text-amber-700 uppercase tracking-wide">{t('stats.pendingBankTransfer')}</div>
|
||||
<div className="text-xs text-amber-700 uppercase tracking-wide">
|
||||
{t('stats.pendingBankTransfer')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-700 mt-1">
|
||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.pendingCarrier')}</div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide">
|
||||
{t('stats.pendingCarrier')}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
||||
</div>
|
||||
@ -184,7 +193,9 @@ export default function AdminBookingsPage() {
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search.label')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('search.label')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
@ -194,7 +205,9 @@ export default function AdminBookingsPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('filter.label')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('filter.label')}
|
||||
</label>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
@ -261,7 +274,9 @@ export default function AdminBookingsPage() {
|
||||
{/* N° Booking */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
{booking.bookingNumber && (
|
||||
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{booking.bookingNumber}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
|
||||
</td>
|
||||
@ -278,11 +293,15 @@ export default function AdminBookingsPage() {
|
||||
<div className="text-sm text-gray-900">
|
||||
{booking.containerType}
|
||||
{booking.palletCount != null && (
|
||||
<span className="ml-1 text-gray-500">· {booking.palletCount} {t('table.pallets')}</span>
|
||||
<span className="ml-1 text-gray-500">
|
||||
· {booking.palletCount} {t('table.pallets')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-x-2">
|
||||
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>}
|
||||
{booking.weightKG != null && (
|
||||
<span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>
|
||||
)}
|
||||
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
||||
</div>
|
||||
</td>
|
||||
@ -294,20 +313,24 @@ export default function AdminBookingsPage() {
|
||||
|
||||
{/* Statut */}
|
||||
<td className="px-4 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||
<span
|
||||
className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}
|
||||
>
|
||||
{getStatusLabel(booking.status)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Date */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(dateLocale)}
|
||||
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(
|
||||
dateLocale
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
if (openMenuId === booking.id) {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
@ -319,7 +342,11 @@ export default function AdminBookingsPage() {
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
@ -336,7 +363,10 @@ export default function AdminBookingsPage() {
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[998]"
|
||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||
@ -355,9 +385,24 @@ export default function AdminBookingsPage() {
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
|
||||
</button>
|
||||
@ -374,10 +419,22 @@ export default function AdminBookingsPage() {
|
||||
disabled={validatingId === openMenuId}
|
||||
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="w-5 h-5 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span>
|
||||
<span className="text-sm font-medium text-green-700">
|
||||
{t('menu.validateTransfer')}
|
||||
</span>
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
@ -391,8 +448,18 @@ export default function AdminBookingsPage() {
|
||||
disabled={deletingId === openMenuId}
|
||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||
>
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
||||
</button>
|
||||
@ -408,11 +475,19 @@ export default function AdminBookingsPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
|
||||
<button
|
||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedBooking(null);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@ -420,60 +495,98 @@ export default function AdminBookingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.bookingNumber')}</label>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.bookingNumber')}
|
||||
</label>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900">
|
||||
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label>
|
||||
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.status')}
|
||||
</label>
|
||||
<span
|
||||
className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}
|
||||
>
|
||||
{getStatusLabel(selectedBooking.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.routeSection')}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
{t('modal.routeSection')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.origin')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.origin || t('modal.none')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.destination')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.destination || t('modal.none')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.cargoSection')}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
{t('modal.cargoSection')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.carrier')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.carrierName || t('modal.none')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.containerType')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.containerType}
|
||||
</div>
|
||||
</div>
|
||||
{selectedBooking.palletCount != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.pallets')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.palletCount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.weightKG != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.weight')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.volumeCBM != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
|
||||
<label className="block text-sm font-medium text-gray-500">
|
||||
{t('modal.volume')}
|
||||
</label>
|
||||
<div className="mt-1 font-semibold text-gray-900">
|
||||
{selectedBooking.volumeCBM} CBM
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -481,18 +594,24 @@ export default function AdminBookingsPage() {
|
||||
|
||||
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.priceSection')}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
{t('modal.priceSection')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedBooking.priceEUR != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString(dateLocale)} €</div>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">
|
||||
{selectedBooking.priceEUR.toLocaleString(dateLocale)} €
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBooking.priceUSD != null && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-500">USD</label>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString(dateLocale)} $</div>
|
||||
<div className="mt-1 text-xl font-bold text-blue-600">
|
||||
{selectedBooking.priceUSD.toLocaleString(dateLocale)} $
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -500,18 +619,24 @@ export default function AdminBookingsPage() {
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">{t('modal.datesSection')}</h3>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">
|
||||
{t('modal.datesSection')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<label className="block text-gray-500">{t('modal.createdAt')}</label>
|
||||
<div className="mt-1 text-gray-900">
|
||||
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)}
|
||||
{new Date(
|
||||
selectedBooking.requestedAt || selectedBooking.createdAt || ''
|
||||
).toLocaleString(dateLocale)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedBooking.updatedAt && (
|
||||
<div>
|
||||
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
|
||||
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}</div>
|
||||
<div className="mt-1 text-gray-900">
|
||||
{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -528,7 +653,9 @@ export default function AdminBookingsPage() {
|
||||
disabled={validatingId === selectedBooking.id}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{validatingId === selectedBooking.id ? t('modal.validating') : t('modal.validateButton')}
|
||||
{validatingId === selectedBooking.id
|
||||
? t('modal.validating')
|
||||
: t('modal.validateButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -536,7 +663,10 @@ export default function AdminBookingsPage() {
|
||||
|
||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||
<button
|
||||
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedBooking(null);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t('modal.close')}
|
||||
|
||||
@ -73,9 +73,7 @@ export default function AdminCsvRatesPage() {
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2">{t('subtitle')}</p>
|
||||
<Badge variant="destructive" className="mt-2">
|
||||
{t('adminBadge')}
|
||||
</Badge>
|
||||
@ -90,9 +88,7 @@ export default function AdminCsvRatesPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('cardTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('cardDescription')}
|
||||
</CardDescription>
|
||||
<CardDescription>{t('cardDescription')}</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
||||
{loading ? (
|
||||
@ -115,9 +111,7 @@ export default function AdminCsvRatesPage() {
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{t('empty')}
|
||||
</div>
|
||||
<div className="text-center py-12 text-muted-foreground">{t('empty')}</div>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
@ -132,15 +126,17 @@ export default function AdminCsvRatesPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
{files.map(file => (
|
||||
<TableRow key={file.filename}>
|
||||
<TableCell className="font-medium font-mono text-xs">{file.filename}</TableCell>
|
||||
<TableCell>
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
<TableCell className="font-medium font-mono text-xs">
|
||||
{file.filename}
|
||||
</TableCell>
|
||||
<TableCell>{(file.size / 1024).toFixed(2)} KB</TableCell>
|
||||
<TableCell>
|
||||
{file.rowCount ? (
|
||||
<span className="font-semibold">{t('table.rowCount', { count: file.rowCount })}</span>
|
||||
<span className="font-semibold">
|
||||
{t('table.rowCount', { count: file.rowCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
|
||||
@ -171,12 +171,16 @@ export default function AdminDocumentsPage() {
|
||||
|
||||
const uniqueUsers = Array.from(
|
||||
new Map(
|
||||
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
|
||||
documents.map(doc => [
|
||||
doc.userId,
|
||||
{ id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' },
|
||||
])
|
||||
).values()
|
||||
);
|
||||
|
||||
const filteredDocuments = documents.filter(doc => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
const matchesSearch =
|
||||
searchTerm === '' ||
|
||||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
@ -184,7 +188,8 @@ export default function AdminDocumentsPage() {
|
||||
|
||||
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
|
||||
|
||||
const matchesQuote = filterQuoteNumber === '' ||
|
||||
const matchesQuote =
|
||||
filterQuoteNumber === '' ||
|
||||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
||||
|
||||
return matchesSearch && matchesUser && matchesQuote;
|
||||
@ -201,7 +206,7 @@ export default function AdminDocumentsPage() {
|
||||
|
||||
const getDocumentIcon = (type: string): ReactNode => {
|
||||
const typeLower = type.toLowerCase();
|
||||
const cls = "h-6 w-6";
|
||||
const cls = 'h-6 w-6';
|
||||
const iconMap: Record<string, ReactNode> = {
|
||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
||||
@ -304,10 +309,7 @@ export default function AdminDocumentsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
description={t('subtitle')}
|
||||
/>
|
||||
<PageHeader title={t('title')} description={t('subtitle')} />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@ -437,7 +439,9 @@ export default function AdminDocumentsPage() {
|
||||
<div className="text-sm text-gray-900">{doc.route}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</td>
|
||||
@ -448,7 +452,7 @@ export default function AdminDocumentsPage() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
const menuKey = `${doc.bookingId}::${doc.id}`;
|
||||
if (openMenuId === menuKey) {
|
||||
setOpenMenuId(null);
|
||||
@ -461,7 +465,11 @@ export default function AdminDocumentsPage() {
|
||||
}}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-600"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
@ -494,9 +502,14 @@ export default function AdminDocumentsPage() {
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '}
|
||||
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '}
|
||||
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')}
|
||||
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
|
||||
{t('pagination.to')}{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(endIndex, filteredDocuments.length)}
|
||||
</span>{' '}
|
||||
{t('pagination.on')}{' '}
|
||||
<span className="font-medium">{filteredDocuments.length}</span>{' '}
|
||||
{t('pagination.results')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
@ -504,7 +517,7 @@ export default function AdminDocumentsPage() {
|
||||
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
onChange={e => {
|
||||
setItemsPerPage(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
@ -517,7 +530,10 @@ export default function AdminDocumentsPage() {
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
@ -525,7 +541,11 @@ export default function AdminDocumentsPage() {
|
||||
>
|
||||
<span className="sr-only">{t('pagination.previous')}</span>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@ -564,7 +584,11 @@ export default function AdminDocumentsPage() {
|
||||
>
|
||||
<span className="sr-only">{t('pagination.next')}</span>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
@ -578,7 +602,10 @@ export default function AdminDocumentsPage() {
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[998]"
|
||||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||||
@ -595,14 +622,29 @@ export default function AdminDocumentsPage() {
|
||||
onClick={() => {
|
||||
setOpenMenuId(null);
|
||||
setMenuPosition(null);
|
||||
handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document');
|
||||
handleDownload(
|
||||
doc.filePath || doc.url || '',
|
||||
doc.fileName || doc.name || 'document'
|
||||
);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
|
||||
>
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{t('menu.download')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -615,8 +657,18 @@ export default function AdminDocumentsPage() {
|
||||
disabled={deletingId === doc.id}
|
||||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||||
>
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
|
||||
</button>
|
||||
|
||||
@ -72,7 +72,9 @@ const LEVEL_ROW_BG: Record<string, string> = {
|
||||
function LevelBadge({ level }: { level: string }) {
|
||||
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}>
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
@ -148,16 +150,14 @@ export default function AdminLogsPage() {
|
||||
if (fmt) params.set('format', fmt);
|
||||
return params.toString();
|
||||
},
|
||||
[filters],
|
||||
[filters]
|
||||
);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await get<LogsResponse>(
|
||||
`${LOGS_PREFIX}/export?${buildQueryString('json')}`,
|
||||
);
|
||||
const data = await get<LogsResponse>(`${LOGS_PREFIX}/export?${buildQueryString('json')}`);
|
||||
setLogs(data.logs || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (err: any) {
|
||||
@ -175,10 +175,7 @@ export default function AdminLogsPage() {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
|
||||
await download(
|
||||
`${LOGS_PREFIX}/export?${buildQueryString(format)}`,
|
||||
filename,
|
||||
);
|
||||
await download(`${LOGS_PREFIX}/export?${buildQueryString(format)}`, filename);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@ -187,8 +184,7 @@ export default function AdminLogsPage() {
|
||||
};
|
||||
|
||||
// Stats
|
||||
const countByLevel = (level: string) =>
|
||||
logs.filter(l => l.level === level).length;
|
||||
const countByLevel = (level: string) => logs.filter(l => l.level === level).length;
|
||||
|
||||
const setFilter = (key: keyof Filters, value: string) =>
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
@ -214,7 +210,9 @@ export default function AdminLogsPage() {
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span>
|
||||
<span className="hidden sm:inline">
|
||||
{exportLoading ? t('exporting') : t('export')}
|
||||
</span>
|
||||
</button>
|
||||
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
|
||||
<button
|
||||
@ -272,7 +270,9 @@ export default function AdminLogsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{/* Service */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.service')}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
{t('filters.service')}
|
||||
</label>
|
||||
<select
|
||||
value={filters.service}
|
||||
onChange={e => setFilter('service', e.target.value)}
|
||||
@ -289,7 +289,9 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Level */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.level')}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
{t('filters.level')}
|
||||
</label>
|
||||
<select
|
||||
value={filters.level}
|
||||
onChange={e => setFilter('level', e.target.value)}
|
||||
@ -306,7 +308,9 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.search')}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
{t('filters.search')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('filters.searchPlaceholder')}
|
||||
@ -319,7 +323,9 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* Start */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.start')}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
{t('filters.start')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.startDate}
|
||||
@ -330,7 +336,9 @@ export default function AdminLogsPage() {
|
||||
|
||||
{/* End */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">{t('filters.end')}</label>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
{t('filters.end')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={filters.endDate}
|
||||
@ -372,9 +380,7 @@ export default function AdminLogsPage() {
|
||||
<span className="text-sm">
|
||||
{t('errorBanner')} <strong>{error}</strong>
|
||||
<br />
|
||||
<span className="text-xs text-red-500">
|
||||
{t('errorHint')}
|
||||
</span>
|
||||
<span className="text-xs text-red-500">{t('errorHint')}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -389,9 +395,7 @@ export default function AdminLogsPage() {
|
||||
</span>
|
||||
</div>
|
||||
{!loading && logs.length > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{t('clickHint')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{t('clickHint')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -469,8 +473,7 @@ export default function AdminLogsPage() {
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
|
||||
{log.req_method && (
|
||||
<span>
|
||||
<span className="font-semibold">{log.req_method}</span>{' '}
|
||||
{log.req_url}{' '}
|
||||
<span className="font-semibold">{log.req_method}</span> {log.req_url}{' '}
|
||||
{log.res_status && (
|
||||
<span
|
||||
className={
|
||||
@ -495,29 +498,37 @@ export default function AdminLogsPage() {
|
||||
<td colSpan={6} className="px-4 py-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span>
|
||||
<span className="font-semibold text-gray-600">
|
||||
{t('detail.timestamp')}
|
||||
</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
|
||||
</div>
|
||||
{log.reqId && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
|
||||
<span className="font-semibold text-gray-600">
|
||||
{t('detail.requestId')}
|
||||
</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5 truncate">
|
||||
{log.reqId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{log.response_time_ms && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-600">{t('detail.duration')}</span>
|
||||
<span className="font-semibold text-gray-600">
|
||||
{t('detail.duration')}
|
||||
</span>
|
||||
<p className="font-mono text-gray-800 mt-0.5">
|
||||
{log.response_time_ms} ms
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2 md:col-span-4">
|
||||
<span className="font-semibold text-gray-600">{t('detail.fullMessage')}</span>
|
||||
<span className="font-semibold text-gray-600">
|
||||
{t('detail.fullMessage')}
|
||||
</span>
|
||||
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{log.error
|
||||
? `[ERROR] ${log.error}\n\n${log.message}`
|
||||
: log.message}
|
||||
{log.error ? `[ERROR] ${log.error}\n\n${log.message}` : log.message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,19 +30,7 @@ interface Organization {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function AdminOrganizationsPage() {
|
||||
const t = useTranslations('dashboard.admin.organizations');
|
||||
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
interface FormData {
|
||||
name: string;
|
||||
type: string;
|
||||
scac: string;
|
||||
@ -54,12 +42,15 @@ export default function AdminOrganizationsPage() {
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
state?: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
logoUrl: string;
|
||||
}>({
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormData = {
|
||||
name: '',
|
||||
type: 'FREIGHT_FORWARDER',
|
||||
scac: '',
|
||||
@ -68,15 +59,34 @@ export default function AdminOrganizationsPage() {
|
||||
eori: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: 'FR',
|
||||
},
|
||||
address: { street: '', city: '', state: '', postalCode: '', country: 'FR' },
|
||||
logoUrl: '',
|
||||
});
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||
return <p className="mt-1 text-xs text-gray-400">{children}</p>;
|
||||
}
|
||||
|
||||
function FieldError({ message }: { message?: string }) {
|
||||
if (!message) return null;
|
||||
return <p className="mt-1 text-xs text-red-600">{message}</p>;
|
||||
}
|
||||
|
||||
export default function AdminOrganizationsPage() {
|
||||
const t = useTranslations('dashboard.admin.organizations');
|
||||
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<string, string>>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrganizations();
|
||||
@ -95,63 +105,120 @@ export default function AdminOrganizationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (isCreate: boolean): boolean => {
|
||||
const errors: Partial<Record<string, string>> = {};
|
||||
|
||||
if (!formData.name.trim() || formData.name.trim().length < 2) {
|
||||
errors.name = t('validation.nameTooShort');
|
||||
}
|
||||
|
||||
if (isCreate && formData.type === 'CARRIER') {
|
||||
if (!formData.scac) {
|
||||
errors.scac = t('validation.scacRequired');
|
||||
} else if (!/^[A-Z]{4}$/.test(formData.scac)) {
|
||||
errors.scac = t('validation.scacFormat');
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.siren && !/^[0-9]{9}$/.test(formData.siren)) {
|
||||
errors.siren = t('validation.sirenFormat');
|
||||
}
|
||||
|
||||
if (formData.siret && !/^[0-9]{14}$/.test(formData.siret)) {
|
||||
errors.siret = t('validation.siretFormat');
|
||||
}
|
||||
|
||||
if (!formData.address.street.trim()) errors.street = t('validation.required');
|
||||
if (!formData.address.city.trim()) errors.city = t('validation.required');
|
||||
if (!formData.address.postalCode.trim()) errors.postalCode = t('validation.required');
|
||||
if (!/^[A-Z]{2}$/.test(formData.address.country)) {
|
||||
errors.country = t('validation.countryFormat');
|
||||
}
|
||||
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const buildApiAddress = () => ({
|
||||
street: formData.address.street,
|
||||
city: formData.address.city,
|
||||
postalCode: formData.address.postalCode,
|
||||
country: formData.address.country,
|
||||
...(formData.address.state ? { state: formData.address.state } : {}),
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!validateForm(true)) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const apiData = {
|
||||
name: formData.name,
|
||||
await createOrganization({
|
||||
name: formData.name.trim(),
|
||||
type: formData.type as any,
|
||||
address_street: formData.address.street,
|
||||
address_city: formData.address.city,
|
||||
address_postal_code: formData.address.postalCode,
|
||||
address_country: formData.address.country,
|
||||
address: buildApiAddress(),
|
||||
contact_email: formData.contact_email || undefined,
|
||||
contact_phone: formData.contact_phone || undefined,
|
||||
logo_url: formData.logoUrl || undefined,
|
||||
};
|
||||
await createOrganization(apiData);
|
||||
logoUrl: formData.logoUrl || undefined,
|
||||
...(formData.siren ? { siren: formData.siren } : {}),
|
||||
...(formData.siret ? { siret: formData.siret } : {}),
|
||||
...(formData.eori ? { eori: formData.eori } : {}),
|
||||
...(formData.scac ? { scac: formData.scac } : {}),
|
||||
});
|
||||
await fetchOrganizations();
|
||||
setShowCreateModal(false);
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
alert(err.message || t('createError'));
|
||||
setFormError(err.message || t('createError'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrg) return;
|
||||
setFormError(null);
|
||||
if (!validateForm(false)) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await updateOrganization(selectedOrg.id, formData);
|
||||
await updateOrganization(selectedOrg.id, {
|
||||
name: formData.name.trim(),
|
||||
address: buildApiAddress(),
|
||||
contact_email: formData.contact_email || undefined,
|
||||
contact_phone: formData.contact_phone || undefined,
|
||||
logoUrl: formData.logoUrl || undefined,
|
||||
isActive: formData.isActive,
|
||||
...(formData.siren ? { siren: formData.siren } : {}),
|
||||
...(formData.siret ? { siret: formData.siret } : {}),
|
||||
...(formData.eori ? { eori: formData.eori } : {}),
|
||||
});
|
||||
await fetchOrganizations();
|
||||
setShowEditModal(false);
|
||||
setSelectedOrg(null);
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
alert(err.message || t('updateError'));
|
||||
setFormError(err.message || t('updateError'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'FREIGHT_FORWARDER',
|
||||
scac: '',
|
||||
siren: '',
|
||||
siret: '',
|
||||
eori: '',
|
||||
contact_phone: '',
|
||||
contact_email: '',
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: 'FR',
|
||||
},
|
||||
logoUrl: '',
|
||||
});
|
||||
setFormData(EMPTY_FORM);
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
};
|
||||
|
||||
const handleTypeChange = (newType: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
type: newType,
|
||||
scac: newType !== 'CARRIER' ? '' : prev.scac,
|
||||
}));
|
||||
setFieldErrors(prev => ({ ...prev, scac: undefined }));
|
||||
};
|
||||
|
||||
const handleVerifySiret = async (orgId: string) => {
|
||||
@ -159,7 +226,12 @@ export default function AdminOrganizationsPage() {
|
||||
setVerifyingId(orgId);
|
||||
const result = await verifySiret(orgId);
|
||||
if (result.verified) {
|
||||
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' }));
|
||||
alert(
|
||||
t('siretVerified', {
|
||||
companyName: result.companyName || 'N/A',
|
||||
address: result.address || 'N/A',
|
||||
})
|
||||
);
|
||||
await fetchOrganizations();
|
||||
} else {
|
||||
alert(result.message || t('siretInvalid'));
|
||||
@ -210,20 +282,41 @@ export default function AdminOrganizationsPage() {
|
||||
eori: org.eori || '',
|
||||
contact_phone: org.contact_phone || '',
|
||||
contact_email: org.contact_email || '',
|
||||
address: org.address,
|
||||
address: {
|
||||
street: org.address.street,
|
||||
city: org.address.city,
|
||||
state: org.address.state || '',
|
||||
postalCode: org.address.postalCode,
|
||||
country: org.address.country,
|
||||
},
|
||||
logoUrl: org.logoUrl || '',
|
||||
isActive: org.isActive,
|
||||
});
|
||||
setFormError(null);
|
||||
setFieldErrors({});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedOrg(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
|
||||
if (allowed.includes(type)) {
|
||||
return t(`types.${type}` as any);
|
||||
}
|
||||
if (allowed.includes(type)) return t(`types.${type}` as any);
|
||||
return type.replace('_', ' ');
|
||||
};
|
||||
|
||||
const inputClass = (hasError?: string) =>
|
||||
`mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
|
||||
hasError
|
||||
? 'border-red-400 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
@ -250,7 +343,6 @@ export default function AdminOrganizationsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
@ -264,17 +356,23 @@ export default function AdminOrganizationsPage() {
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
|
||||
<span className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
org.type === 'FREIGHT_FORWARDER' ? 'bg-blue-100 text-blue-800' :
|
||||
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
<span
|
||||
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
org.type === 'FREIGHT_FORWARDER'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: org.type === 'CARRIER'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(org.type)}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{org.isActive ? t('active') : t('inactive')}
|
||||
</span>
|
||||
</div>
|
||||
@ -315,7 +413,8 @@ export default function AdminOrganizationsPage() {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country}
|
||||
<span className="font-medium">{t('location')}:</span> {org.address.city},{' '}
|
||||
{org.address.country}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -363,205 +462,365 @@ export default function AdminOrganizationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{/* Create / Edit Modal */}
|
||||
{(showCreateModal || showEditModal) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto py-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
|
||||
</h2>
|
||||
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
||||
|
||||
<form
|
||||
onSubmit={showCreateModal ? handleCreate : handleUpdate}
|
||||
noValidate
|
||||
className="space-y-5"
|
||||
>
|
||||
{/* ── Informations générales ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{t('modal.sectionGeneral')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.name')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, name: e.target.value });
|
||||
setFieldErrors(prev => ({ ...prev, name: undefined }));
|
||||
}}
|
||||
minLength={2}
|
||||
maxLength={200}
|
||||
className={inputClass(fieldErrors.name)}
|
||||
placeholder="Acme Freight Forwarding"
|
||||
/>
|
||||
<FieldError message={fieldErrors.name} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.type')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.type')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
{showCreateModal ? (
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
className={inputClass()}
|
||||
>
|
||||
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
|
||||
<option value="CARRIER">{t('types.CARRIER')}</option>
|
||||
<option value="SHIPPER">{t('types.SHIPPER')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.type === 'CARRIER' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
required={formData.type === 'CARRIER'}
|
||||
maxLength={4}
|
||||
value={formData.scac}
|
||||
onChange={e => setFormData({ ...formData, scac: e.target.value.toUpperCase() })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
formData.type === 'FREIGHT_FORWARDER'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: formData.type === 'CARRIER'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}
|
||||
>
|
||||
{getTypeLabel(formData.type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{t('modal.typeReadOnly')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SCAC — création uniquement, visible en lecture seule en édition si CARRIER */}
|
||||
{showCreateModal && formData.type === 'CARRIER' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.scacLabel')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.scac}
|
||||
onChange={e => {
|
||||
const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||
setFormData({ ...formData, scac: v });
|
||||
setFieldErrors(prev => ({ ...prev, scac: undefined }));
|
||||
}}
|
||||
maxLength={4}
|
||||
minLength={4}
|
||||
className={inputClass(fieldErrors.scac)}
|
||||
placeholder="MAEU"
|
||||
/>
|
||||
<FieldHint>{t('modal.scacHint')}</FieldHint>
|
||||
<FieldError message={fieldErrors.scac} />
|
||||
</div>
|
||||
)}
|
||||
{showEditModal && formData.type === 'CARRIER' && formData.scac && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.scacLabel')}
|
||||
</label>
|
||||
<div className="mt-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 font-mono">
|
||||
{formData.scac}
|
||||
</div>
|
||||
<FieldHint>{t('modal.typeReadOnly')}</FieldHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Identifiants légaux ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{t('modal.sectionLegal')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.sirenLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={9}
|
||||
value={formData.siren}
|
||||
onChange={e => setFormData({ ...formData, siren: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 9);
|
||||
setFormData({ ...formData, siren: v });
|
||||
setFieldErrors(prev => ({ ...prev, siren: undefined }));
|
||||
}}
|
||||
maxLength={9}
|
||||
className={inputClass(fieldErrors.siren)}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
<FieldHint>{t('modal.sirenHint')}</FieldHint>
|
||||
<FieldError message={fieldErrors.siren} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.siretLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={14}
|
||||
value={formData.siret}
|
||||
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder={t('modal.siretPlaceholder')}
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 14);
|
||||
setFormData({ ...formData, siret: v });
|
||||
setFieldErrors(prev => ({ ...prev, siret: undefined }));
|
||||
}}
|
||||
maxLength={14}
|
||||
className={inputClass(fieldErrors.siret)}
|
||||
placeholder="12345678901234"
|
||||
/>
|
||||
<FieldHint>{t('modal.siretHint')}</FieldHint>
|
||||
<FieldError message={fieldErrors.siret} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.eoriLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.eori}
|
||||
onChange={e => setFormData({ ...formData, eori: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
className={inputClass()}
|
||||
placeholder="FR123456789"
|
||||
/>
|
||||
<FieldHint>{t('modal.eoriHint')}</FieldHint>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.contact_phone}
|
||||
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Contact ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{t('modal.sectionContact')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.contactEmail')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.contact_email}
|
||||
onChange={e => setFormData({ ...formData, contact_email: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
className={inputClass()}
|
||||
placeholder="contact@exemple.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.contactPhone')}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.contact_phone}
|
||||
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })}
|
||||
className={inputClass()}
|
||||
placeholder="+33 6 00 00 00 00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Adresse ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{t('modal.sectionAddress')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.street')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.address.street}
|
||||
onChange={e => setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, street: e.target.value }
|
||||
})}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, address: { ...formData.address, street: e.target.value } });
|
||||
setFieldErrors(prev => ({ ...prev, street: undefined }));
|
||||
}}
|
||||
className={inputClass(fieldErrors.street)}
|
||||
placeholder="123 Rue de la République"
|
||||
/>
|
||||
<FieldError message={fieldErrors.street} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.city')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.address.city}
|
||||
onChange={e => setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, city: e.target.value }
|
||||
})}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, address: { ...formData.address, city: e.target.value } });
|
||||
setFieldErrors(prev => ({ ...prev, city: undefined }));
|
||||
}}
|
||||
className={inputClass(fieldErrors.city)}
|
||||
placeholder="Paris"
|
||||
/>
|
||||
<FieldError message={fieldErrors.city} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.postalCode')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.address.postalCode}
|
||||
onChange={e => setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, postalCode: e.target.value }
|
||||
})}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
setFormData({ ...formData, address: { ...formData.address, postalCode: e.target.value } });
|
||||
setFieldErrors(prev => ({ ...prev, postalCode: undefined }));
|
||||
}}
|
||||
className={inputClass(fieldErrors.postalCode)}
|
||||
placeholder="75001"
|
||||
/>
|
||||
<FieldError message={fieldErrors.postalCode} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.state')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address.state}
|
||||
onChange={e => setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, state: e.target.value }
|
||||
})}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e =>
|
||||
setFormData({ ...formData, address: { ...formData.address, state: e.target.value } })
|
||||
}
|
||||
className={inputClass()}
|
||||
placeholder="Île-de-France"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.country')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength={2}
|
||||
value={formData.address.country}
|
||||
onChange={e => setFormData({
|
||||
...formData,
|
||||
address: { ...formData.address, country: e.target.value.toUpperCase() }
|
||||
})}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
onChange={e => {
|
||||
const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
|
||||
setFormData({ ...formData, address: { ...formData.address, country: v } });
|
||||
setFieldErrors(prev => ({ ...prev, country: undefined }));
|
||||
}}
|
||||
maxLength={2}
|
||||
minLength={2}
|
||||
className={inputClass(fieldErrors.country)}
|
||||
placeholder="FR"
|
||||
/>
|
||||
<FieldHint>{t('modal.countryHint')}</FieldHint>
|
||||
<FieldError message={fieldErrors.country} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label>
|
||||
{/* ── Autres ── */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{t('modal.sectionOther')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.logoUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.logoUrl}
|
||||
onChange={e => setFormData({ ...formData, logoUrl: e.target.value })}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||
className={inputClass()}
|
||||
placeholder="https://exemple.com/logo.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
{showEditModal && (
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="isActive"
|
||||
type="checkbox"
|
||||
checked={formData.isActive}
|
||||
onChange={e => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||
{t('modal.isActive')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Erreur globale */}
|
||||
{formError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-2 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedOrg(null);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{t('modal.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{showCreateModal ? t('modal.create') : t('modal.update')}
|
||||
{submitting
|
||||
? t('modal.saving')
|
||||
: showCreateModal
|
||||
? t('modal.create')
|
||||
: t('modal.update')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -228,11 +228,15 @@ export default function AdminUsersPage() {
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' :
|
||||
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: user.role === 'MANAGER'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{getRoleLabel(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
@ -240,9 +244,11 @@ export default function AdminUsersPage() {
|
||||
{user.organizationName || user.organizationId}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? t('active') : t('inactive')}
|
||||
</span>
|
||||
</td>
|
||||
@ -273,7 +279,9 @@ export default function AdminUsersPage() {
|
||||
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
@ -283,7 +291,9 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.firstName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -293,7 +303,9 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.lastName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -316,7 +328,9 @@ export default function AdminUsersPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.organization')}
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.organizationId}
|
||||
@ -372,7 +386,9 @@ export default function AdminUsersPage() {
|
||||
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
|
||||
<form onSubmit={handleUpdate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.emailReadOnly')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
disabled
|
||||
@ -381,7 +397,9 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.firstName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -391,7 +409,9 @@ export default function AdminUsersPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('modal.lastName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
@ -443,7 +463,10 @@ export default function AdminUsersPage() {
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
|
||||
<p className="text-gray-700 mb-6">
|
||||
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })}
|
||||
{t('deleteConfirm.message', {
|
||||
firstName: selectedUser.firstName,
|
||||
lastName: selectedUser.lastName,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
|
||||
@ -19,8 +19,12 @@ import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
||||
import { getOrganization, requestSiretApproval } from '@/lib/api/organizations';
|
||||
import { useAuth } from '@/lib/context/auth-context';
|
||||
|
||||
interface BookingData {
|
||||
id: string;
|
||||
@ -42,6 +46,13 @@ interface BookingData {
|
||||
commissionAmountEur?: number;
|
||||
}
|
||||
|
||||
interface OrgData {
|
||||
siren?: string | null;
|
||||
siret?: string | null;
|
||||
siretVerified?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type PaymentMethod = 'card' | 'transfer' | null;
|
||||
|
||||
const BANK_DETAILS = {
|
||||
@ -54,21 +65,32 @@ export default function PayCommissionPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const bookingId = params.id as string;
|
||||
const { user } = useAuth();
|
||||
|
||||
const [booking, setBooking] = useState<BookingData | null>(null);
|
||||
const [org, setOrg] = useState<OrgData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [declaring, setDeclaring] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [requestingApproval, setRequestingApproval] = useState(false);
|
||||
const [approvalSent, setApprovalSent] = useState(false);
|
||||
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBooking() {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const data = await getCsvBooking(bookingId);
|
||||
setBooking(data as any);
|
||||
if (data.status !== 'PENDING_PAYMENT') {
|
||||
const [bookingData, orgData] = await Promise.all([
|
||||
getCsvBooking(bookingId),
|
||||
user?.organizationId ? getOrganization(user.organizationId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
setBooking(bookingData as any);
|
||||
if (orgData) setOrg(orgData);
|
||||
|
||||
if ((bookingData as any).status !== 'PENDING_PAYMENT') {
|
||||
router.replace('/dashboard/bookings');
|
||||
}
|
||||
} catch (err) {
|
||||
@ -77,8 +99,8 @@ export default function PayCommissionPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (bookingId) fetchBooking();
|
||||
}, [bookingId, router]);
|
||||
if (bookingId) fetchData();
|
||||
}, [bookingId, router, user?.organizationId]);
|
||||
|
||||
const handlePayByCard = async () => {
|
||||
setPaying(true);
|
||||
@ -104,6 +126,21 @@ export default function PayCommissionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestApproval = async () => {
|
||||
setRequestingApproval(true);
|
||||
setApprovalError(null);
|
||||
try {
|
||||
await requestSiretApproval();
|
||||
setApprovalSent(true);
|
||||
} catch (err) {
|
||||
setApprovalError(
|
||||
err instanceof Error ? err.message : 'Erreur lors de l\'envoi de la demande'
|
||||
);
|
||||
} finally {
|
||||
setRequestingApproval(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (value: string, key: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
@ -113,6 +150,14 @@ export default function PayCommissionPage() {
|
||||
const formatPrice = (price: number, currency: string) =>
|
||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
||||
|
||||
const hasSiretOrSiren = org && (org.siret || org.siren);
|
||||
const siretNotVerified = hasSiretOrSiren && !org.siretVerified;
|
||||
const identifier = org?.siret
|
||||
? `SIRET ${org.siret}`
|
||||
: org?.siren
|
||||
? `SIREN ${org.siren}`
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
@ -164,6 +209,60 @@ export default function PayCommissionPage() {
|
||||
Finalisez votre booking en réglant la commission de service
|
||||
</p>
|
||||
|
||||
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
||||
{siretNotVerified && (
|
||||
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<ShieldCheck className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-amber-900 mb-1">
|
||||
Votre {identifier} n'est pas encore validé
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700 mb-4">
|
||||
Un administrateur doit vérifier votre numéro d'identification avant de
|
||||
finaliser certaines opérations. Vous pouvez lui envoyer une demande de validation
|
||||
directement depuis cette page.
|
||||
</p>
|
||||
|
||||
{approvalSent ? (
|
||||
<div className="flex items-center space-x-2 text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
Demande envoyée aux administrateurs. Vous serez notifié dès que votre{' '}
|
||||
{identifier} sera validé.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{approvalError && (
|
||||
<p className="text-sm text-red-600">{approvalError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRequestApproval}
|
||||
disabled={requestingApproval}
|
||||
className="inline-flex items-center space-x-2 px-4 py-2.5 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
|
||||
>
|
||||
{requestingApproval ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Envoi en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
<span>Demander la validation à un administrateur</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
@ -210,9 +309,7 @@ export default function PayCommissionPage() {
|
||||
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{selectedMethod === 'card' && (
|
||||
<div className="w-2 h-2 rounded-full bg-white" />
|
||||
)}
|
||||
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@ -422,6 +519,29 @@ export default function PayCommissionPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Organisation info */}
|
||||
{org && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-2 text-sm">
|
||||
<p className="font-medium text-gray-700">{org.name}</p>
|
||||
{identifier && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">{identifier}</span>
|
||||
{org.siretVerified ? (
|
||||
<span className="flex items-center space-x-1 text-green-600 text-xs font-medium">
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
<span>Vérifié</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center space-x-1 text-amber-600 text-xs font-medium">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>Non vérifié</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-gray-500">
|
||||
|
||||
@ -32,9 +32,7 @@ export default function PaymentSuccessPage() {
|
||||
setStatus('success');
|
||||
} catch (err) {
|
||||
console.error('Payment confirmation error:', err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement'
|
||||
);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors de la confirmation du paiement');
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
@ -68,7 +66,8 @@ export default function PaymentSuccessPage() {
|
||||
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
|
||||
<p className="text-gray-600">
|
||||
Veuillez patienter pendant que nous verifions votre paiement et activons votre booking.
|
||||
Veuillez patienter pendant que nous verifions votre paiement et activons votre
|
||||
booking.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@ -82,15 +81,14 @@ export default function PaymentSuccessPage() {
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec votre demande de booking.
|
||||
Votre commission a ete payee avec succes. Un email a ete envoye au transporteur avec
|
||||
votre demande de booking.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-center space-x-2 text-blue-700">
|
||||
<Mail className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">
|
||||
Email envoye au transporteur
|
||||
</span>
|
||||
<span className="text-sm font-medium">Email envoye au transporteur</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
|
||||
@ -113,7 +111,8 @@ export default function PaymentSuccessPage() {
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
|
||||
<p className="text-gray-600 mb-2">{error}</p>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Si votre paiement a ete debite, contactez le support. Votre booking sera active manuellement.
|
||||
Si votre paiement a ete debite, contactez le support. Votre booking sera active
|
||||
manuellement.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user