diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json index 795b271..c8302ac 100644 --- a/apps/backend/nest-cli.json +++ b/apps/backend/nest-cli.json @@ -6,6 +6,8 @@ "deleteOutDir": true, "builder": "tsc", "tsConfigPath": "tsconfig.build.json", - "plugins": ["@nestjs/swagger"] + "plugins": ["@nestjs/swagger"], + "assets": [{ "include": "i18n/**/*.json", "outDir": "dist" }], + "watchAssets": true } } diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index ec5c11b..314ba0b 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -43,6 +43,7 @@ "joi": "^17.11.0", "leaflet": "^1.9.4", "mjml": "^4.16.1", + "nestjs-i18n": "^10.6.5", "nestjs-pino": "^4.4.1", "nodemailer": "^7.0.9", "opossum": "^8.1.3", @@ -5761,6 +5762,12 @@ "node": ">=6.5" } }, + "node_modules/accept-language-parser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", + "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -12169,6 +12176,34 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "license": "MIT" }, + "node_modules/nestjs-i18n": { + "version": "10.6.5", + "resolved": "https://registry.npmjs.org/nestjs-i18n/-/nestjs-i18n-10.6.5.tgz", + "integrity": "sha512-jqbZ+H7LMEfAVYqS1FM0YfZjzPDwZQq97NE4BBIfPpxzAhlfnPzaQDGpNkPE/5Ft+rawtNJOuuuaWMpDhSLwaA==", + "license": "MIT", + "dependencies": { + "accept-language-parser": "^1.5.0", + "chokidar": "^3.6.0", + "cookie": "^0.7.0", + "iterare": "^1.2.1", + "js-yaml": "^4.1.0", + "string-format": "^2.0.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@nestjs/common": "*", + "@nestjs/core": "*", + "class-validator": "*", + "rxjs": "*" + }, + "peerDependenciesMeta": { + "class-validator": { + "optional": true + } + } + }, "node_modules/nestjs-pino": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz", @@ -14472,6 +14507,12 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-format": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/string-format/-/string-format-2.0.0.tgz", + "integrity": "sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==", + "license": "WTFPL OR MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 82ec0e6..e4b30a7 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -59,6 +59,7 @@ "joi": "^17.11.0", "leaflet": "^1.9.4", "mjml": "^4.16.1", + "nestjs-i18n": "^10.6.5", "nestjs-pino": "^4.4.1", "nodemailer": "^7.0.9", "opossum": "^8.1.3", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0ac03c6..eece0f4 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,7 +3,16 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggerModule } from 'nestjs-pino'; import { APP_GUARD } from '@nestjs/core'; +import { + AcceptLanguageResolver, + CookieResolver, + HeaderResolver, + I18nModule, + QueryResolver, +} from 'nestjs-i18n'; +import * as path from 'path'; import * as Joi from 'joi'; +import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver'; // Import feature modules import { AuthModule } from './application/auth/auth.module'; @@ -110,6 +119,29 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; inject: [ConfigService], }), + // Internationalization (FR / EN) + // Resolver chain (highest priority first): + // 1. UserPreferenceResolver — authenticated user's preferredLanguage + // 2. CookieResolver (NEXT_LOCALE) — set by frontend switcher + // 3. HeaderResolver (x-lang / x-locale) + // 4. QueryResolver (?lang=xx) + // 5. AcceptLanguageResolver + // 6. fallback → 'fr' + I18nModule.forRoot({ + fallbackLanguage: 'fr', + loaderOptions: { + path: path.join(__dirname, '/i18n/'), + watch: true, + }, + resolvers: [ + UserPreferenceResolver, + new CookieResolver(['NEXT_LOCALE', 'lang']), + new HeaderResolver(['x-lang', 'x-locale']), + new QueryResolver(['lang', 'locale']), + AcceptLanguageResolver, + ], + }), + // Database TypeOrmModule.forRootAsync({ useFactory: (configService: ConfigService) => ({ diff --git a/apps/backend/src/application/api-keys/api-keys.controller.ts b/apps/backend/src/application/api-keys/api-keys.controller.ts index b2bb476..bc79dfb 100644 --- a/apps/backend/src/application/api-keys/api-keys.controller.ts +++ b/apps/backend/src/application/api-keys/api-keys.controller.ts @@ -10,13 +10,7 @@ import { Post, UseGuards, } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiResponse, - ApiSecurity, - ApiTags, -} from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../decorators/current-user.decorator'; import { RequiresFeature } from '../decorators/requires-feature.decorator'; @@ -38,7 +32,7 @@ export class ApiKeysController { @ApiOperation({ summary: 'Générer une nouvelle clé API', description: - "Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.", + 'Crée une clé API pour accès programmatique. La clé complète est retournée **une seule fois** — conservez-la immédiatement. Réservé aux abonnements Gold et Platinium.', }) @ApiResponse({ status: 201, diff --git a/apps/backend/src/application/api-keys/api-keys.module.ts b/apps/backend/src/application/api-keys/api-keys.module.ts index d3a67aa..40663b9 100644 --- a/apps/backend/src/application/api-keys/api-keys.module.ts +++ b/apps/backend/src/application/api-keys/api-keys.module.ts @@ -23,10 +23,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { FeatureFlagGuard } from '../guards/feature-flag.guard'; @Module({ - imports: [ - TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), - SubscriptionsModule, - ], + imports: [TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), SubscriptionsModule], controllers: [ApiKeysController], providers: [ ApiKeysService, diff --git a/apps/backend/src/application/api-keys/api-keys.service.ts b/apps/backend/src/application/api-keys/api-keys.service.ts index eeac338..3ae100c 100644 --- a/apps/backend/src/application/api-keys/api-keys.service.ts +++ b/apps/backend/src/application/api-keys/api-keys.service.ts @@ -8,13 +8,7 @@ * - Validation for inbound API key authentication */ -import { - ForbiddenException, - Inject, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; +import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import * as crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; diff --git a/apps/backend/src/application/auth/auth.module.ts b/apps/backend/src/application/auth/auth.module.ts index fd96f43..e044f9f 100644 --- a/apps/backend/src/application/auth/auth.module.ts +++ b/apps/backend/src/application/auth/auth.module.ts @@ -41,7 +41,12 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; }), // 👇 Add this to register TypeORM repositories - TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]), + TypeOrmModule.forFeature([ + UserOrmEntity, + OrganizationOrmEntity, + InvitationTokenOrmEntity, + PasswordResetTokenOrmEntity, + ]), // Email module for sending invitations EmailModule, diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index d5c0d18..208c9f9 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -265,7 +265,9 @@ export class AuthService { } if (resetToken.expiresAt < new Date()) { - throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'); + throw new BadRequestException( + 'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.' + ); } const user = await this.userRepository.findById(resetToken.userId); @@ -286,10 +288,7 @@ export class AuthService { await this.userRepository.save(user); // Mark token as used - await this.passwordResetTokenRepository.update( - { id: resetToken.id }, - { usedAt: new Date() } - ); + await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() }); this.logger.log(`Password reset successfully for user: ${user.email}`); } diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index 745afff..a4ccb99 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -744,10 +744,7 @@ export class AdminController { }) @ApiResponse({ status: 200, description: 'Email sent successfully' }) @ApiResponse({ status: 400, description: 'SMTP error — check the message field' }) - async sendTestEmail( - @Body() body: { to: string }, - @CurrentUser() user: UserPayload - ) { + async sendTestEmail(@Body() body: { to: string }, @CurrentUser() user: UserPayload) { if (!body?.to) { throw new BadRequestException('Field "to" is required'); } @@ -880,7 +877,9 @@ export class AdminController { @Param('documentId', ParseUUIDPipe) documentId: string, @CurrentUser() user: UserPayload ): Promise<{ success: boolean; message: string }> { - this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`); + this.logger.log( + `[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}` + ); const booking = await this.csvBookingRepository.findById(bookingId); if (!booking) { @@ -894,7 +893,9 @@ export class AdminController { const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); - const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } }); + const ormBooking = await this.csvBookingRepository['repository'].findOne({ + where: { id: bookingId }, + }); if (ormBooking) { ormBooking.documents = updatedDocuments.map(doc => ({ id: doc.id, diff --git a/apps/backend/src/application/controllers/auth.controller.ts b/apps/backend/src/application/controllers/auth.controller.ts index d35f172..756bcea 100644 --- a/apps/backend/src/application/controllers/auth.controller.ts +++ b/apps/backend/src/application/controllers/auth.controller.ts @@ -289,7 +289,9 @@ export class AuthController { }); } catch (error) { this.logger.error(`Failed to send contact email: ${error}`); - throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer."); + throw new InternalServerErrorException( + "Erreur lors de l'envoi du message. Veuillez réessayer." + ); } return { message: 'Message envoyé avec succès.' }; diff --git a/apps/backend/src/application/controllers/invitations.controller.ts b/apps/backend/src/application/controllers/invitations.controller.ts index e596276..ddfc08a 100644 --- a/apps/backend/src/application/controllers/invitations.controller.ts +++ b/apps/backend/src/application/controllers/invitations.controller.ts @@ -153,10 +153,7 @@ export class InvitationsController { @ApiResponse({ status: 204, description: 'Invitation cancelled' }) @ApiResponse({ status: 404, description: 'Invitation not found' }) @ApiResponse({ status: 400, description: 'Invitation already used' }) - async cancelInvitation( - @Param('id') id: string, - @CurrentUser() user: UserPayload - ): Promise { + async cancelInvitation(@Param('id') id: string, @CurrentUser() user: UserPayload): Promise { this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`); await this.invitationService.cancelInvitation(id, user.organizationId); } diff --git a/apps/backend/src/application/decorators/current-user.decorator.ts b/apps/backend/src/application/decorators/current-user.decorator.ts index 713840c..5596c4f 100644 --- a/apps/backend/src/application/decorators/current-user.decorator.ts +++ b/apps/backend/src/application/decorators/current-user.decorator.ts @@ -1,4 +1,5 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Locale } from '@domain/value-objects/locale.vo'; /** * User payload interface extracted from JWT @@ -10,6 +11,7 @@ export interface UserPayload { organizationId: string; firstName: string; lastName: string; + preferredLanguage?: Locale; } /** diff --git a/apps/backend/src/application/filters/domain-exception.filter.ts b/apps/backend/src/application/filters/domain-exception.filter.ts new file mode 100644 index 0000000..d45dad0 --- /dev/null +++ b/apps/backend/src/application/filters/domain-exception.filter.ts @@ -0,0 +1,44 @@ +/** + * DomainExceptionFilter + * + * Catches any DomainException bubbling up to the HTTP boundary, translates its + * i18nKey/i18nArgs into the caller's locale (resolved by nestjs-i18n) and + * returns a structured JSON error response. + * + * Non-domain errors fall through to NestJS's default handler. + */ + +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common'; +import { I18nService, I18nContext } from 'nestjs-i18n'; +import { Response, Request } from 'express'; +import { DomainException } from '@domain/exceptions/domain.exception'; +import { DEFAULT_LOCALE, Locale, toLocale } from '@domain/value-objects/locale.vo'; + +@Catch(DomainException) +export class DomainExceptionFilter implements ExceptionFilter { + constructor(private readonly i18n: I18nService>) {} + + catch(exception: DomainException, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const lang: Locale = toLocale(I18nContext.current()?.lang, DEFAULT_LOCALE) ?? DEFAULT_LOCALE; + + const translated = this.i18n.translate(exception.i18nKey, { + lang, + args: exception.i18nArgs, + defaultValue: exception.message, + }); + + const status = exception.status || HttpStatus.BAD_REQUEST; + + response.status(status).json({ + statusCode: status, + error: exception.name, + message: typeof translated === 'string' ? translated : exception.message, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/apps/backend/src/application/logs/logs.controller.ts b/apps/backend/src/application/logs/logs.controller.ts index 1926c1e..9f83512 100644 --- a/apps/backend/src/application/logs/logs.controller.ts +++ b/apps/backend/src/application/logs/logs.controller.ts @@ -1,12 +1,4 @@ -import { - Controller, - Get, - Query, - Res, - UseGuards, - HttpException, - HttpStatus, -} from '@nestjs/common'; +import { Controller, Get, Query, Res, UseGuards, HttpException, HttpStatus } from '@nestjs/common'; import { Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; @@ -22,7 +14,7 @@ export class LogsController { constructor(private readonly configService: ConfigService) { this.logExporterUrl = this.configService.get( 'LOG_EXPORTER_URL', - 'http://xpeditis-log-exporter:3200', + 'http://xpeditis-log-exporter:3200' ); } @@ -39,10 +31,7 @@ export class LogsController { if (!res.ok) throw new Error(`log-exporter error: ${res.status}`); return res.json(); } catch (err: any) { - throw new HttpException( - { error: err.message }, - HttpStatus.BAD_GATEWAY, - ); + throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY); } } @@ -59,7 +48,7 @@ export class LogsController { @Query('end') end: string, @Query('limit') limit: string, @Query('format') format: string = 'json', - @Res() res: Response, + @Res() res: Response ) { try { const params = new URLSearchParams(); @@ -71,10 +60,9 @@ export class LogsController { if (limit) params.set('limit', limit); params.set('format', format); - const upstream = await fetch( - `${this.logExporterUrl}/api/logs/export?${params}`, - { signal: AbortSignal.timeout(30000) }, - ); + const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, { + signal: AbortSignal.timeout(30000), + }); if (!upstream.ok) { const body = await upstream.json().catch(() => ({})); diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index 5588347..0de8541 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -374,18 +374,20 @@ export class CsvBookingService { booking.markBankTransferDeclared(); const updatedBooking = await this.csvBookingRepository.update(booking); - this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`); + this.logger.log( + `Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER` + ); // Send email to all ADMIN users try { const allUsers = await this.userRepository.findAll(); - const adminEmails = allUsers - .filter(u => u.role === 'ADMIN' && u.isActive) - .map(u => u.email); + const adminEmails = allUsers.filter(u => u.role === 'ADMIN' && u.isActive).map(u => u.email); if (adminEmails.length > 0) { const commissionAmount = booking.commissionAmountEur - ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur) + ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format( + booking.commissionAmountEur + ) : 'N/A'; await this.emailAdapter.send({ @@ -488,7 +490,9 @@ export class CsvBookingService { notes: booking.notes, }); - this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`); + this.logger.log( + `[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}` + ); } /** @@ -544,7 +548,9 @@ export class CsvBookingService { confirmationToken: booking.confirmationToken, notes: booking.notes, }); - this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`); + this.logger.log( + `Email sent to carrier after bank transfer validation: ${booking.carrierEmail}` + ); } catch (error: any) { this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); } diff --git a/apps/backend/src/application/services/invitation.service.ts b/apps/backend/src/application/services/invitation.service.ts index 06ff751..3543023 100644 --- a/apps/backend/src/application/services/invitation.service.ts +++ b/apps/backend/src/application/services/invitation.service.ts @@ -70,7 +70,10 @@ export class InvitationService { } // Check if licenses are available for this organization - const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole); + const canInviteResult = await this.subscriptionService.canInviteUser( + organizationId, + inviterRole + ); if (!canInviteResult.canInvite) { this.logger.warn( `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` diff --git a/apps/backend/src/domain/entities/organization.entity.ts b/apps/backend/src/domain/entities/organization.entity.ts index 4cfa76c..026a201 100644 --- a/apps/backend/src/domain/entities/organization.entity.ts +++ b/apps/backend/src/domain/entities/organization.entity.ts @@ -10,6 +10,8 @@ * - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER) */ +import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo'; + export enum OrganizationType { FREIGHT_FORWARDER = 'FREIGHT_FORWARDER', CARRIER = 'CARRIER', @@ -47,6 +49,7 @@ export interface OrganizationProps { siret?: string; siretVerified: boolean; statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; + defaultLanguage: Locale; createdAt: Date; updatedAt: Date; isActive: boolean; @@ -63,9 +66,13 @@ export class Organization { * Factory method to create a new Organization */ static create( - props: Omit & { + props: Omit< + OrganizationProps, + 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge' | 'defaultLanguage' + > & { siretVerified?: boolean; statusBadge?: 'none' | 'silver' | 'gold' | 'platinium'; + defaultLanguage?: Locale; } ): Organization { const now = new Date(); @@ -94,6 +101,7 @@ export class Organization { ...props, siretVerified: props.siretVerified ?? false, statusBadge: props.statusBadge ?? 'none', + defaultLanguage: props.defaultLanguage ?? DEFAULT_LOCALE, createdAt: now, updatedAt: now, }); @@ -188,6 +196,15 @@ export class Organization { return this.props.isActive; } + get defaultLanguage(): Locale { + return this.props.defaultLanguage; + } + + updateDefaultLanguage(locale: Locale): void { + this.props.defaultLanguage = locale; + this.props.updatedAt = new Date(); + } + // Business methods isCarrier(): boolean { return this.props.type === OrganizationType.CARRIER; diff --git a/apps/backend/src/domain/entities/user.entity.ts b/apps/backend/src/domain/entities/user.entity.ts index ea21328..42e2b7d 100644 --- a/apps/backend/src/domain/entities/user.entity.ts +++ b/apps/backend/src/domain/entities/user.entity.ts @@ -10,6 +10,8 @@ * - Role-based access control (Admin, Manager, User, Viewer) */ +import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo'; + export enum UserRole { ADMIN = 'ADMIN', // Full system access MANAGER = 'MANAGER', // Manage bookings and users within organization @@ -30,6 +32,7 @@ export interface UserProps { isEmailVerified: boolean; isActive: boolean; lastLoginAt?: Date; + preferredLanguage: Locale; createdAt: Date; updatedAt: Date; } @@ -47,8 +50,13 @@ export class User { static create( props: Omit< UserProps, - 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt' - > + | 'createdAt' + | 'updatedAt' + | 'isEmailVerified' + | 'isActive' + | 'lastLoginAt' + | 'preferredLanguage' + > & { preferredLanguage?: Locale } ): User { const now = new Date(); @@ -59,6 +67,7 @@ export class User { return new User({ ...props, + preferredLanguage: props.preferredLanguage ?? DEFAULT_LOCALE, isEmailVerified: false, isActive: true, createdAt: now, @@ -142,6 +151,15 @@ export class User { return this.props.updatedAt; } + get preferredLanguage(): Locale { + return this.props.preferredLanguage; + } + + updatePreferredLanguage(locale: Locale): void { + this.props.preferredLanguage = locale; + this.props.updatedAt = new Date(); + } + // Business methods has2FAEnabled(): boolean { return !!this.props.totpSecret; diff --git a/apps/backend/src/domain/exceptions/domain.exception.ts b/apps/backend/src/domain/exceptions/domain.exception.ts new file mode 100644 index 0000000..917c4d2 --- /dev/null +++ b/apps/backend/src/domain/exceptions/domain.exception.ts @@ -0,0 +1,30 @@ +/** + * DomainException (Base) + * + * Base class for all translatable domain exceptions. + * Exceptions carry an i18n key + optional args so the application-layer + * exception filter can translate them into the caller's locale at the HTTP + * response boundary. + * + * Subclasses should: + * - Pass an i18nKey (e.g. 'error.PORT_NOT_FOUND') + * - Pass i18nArgs for interpolation (e.g. { portCode }) + * - Optionally override `status` (HTTP status, default 400) + */ + +export type I18nArgs = Record; + +export abstract class DomainException extends Error { + public readonly i18nKey: string; + public readonly i18nArgs: I18nArgs; + public readonly status: number; + + constructor(i18nKey: string, i18nArgs: I18nArgs = {}, fallbackMessage?: string, status = 400) { + super(fallbackMessage ?? i18nKey); + this.i18nKey = i18nKey; + this.i18nArgs = i18nArgs; + this.status = status; + this.name = this.constructor.name; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/apps/backend/src/domain/exceptions/index.ts b/apps/backend/src/domain/exceptions/index.ts index a35e026..796255d 100644 --- a/apps/backend/src/domain/exceptions/index.ts +++ b/apps/backend/src/domain/exceptions/index.ts @@ -4,6 +4,7 @@ * All domain exceptions for the Xpeditis platform */ +export * from './domain.exception'; export * from './invalid-port-code.exception'; export * from './invalid-rate-quote.exception'; export * from './carrier-timeout.exception'; diff --git a/apps/backend/src/domain/exceptions/port-not-found.exception.ts b/apps/backend/src/domain/exceptions/port-not-found.exception.ts index c886989..0e351eb 100644 --- a/apps/backend/src/domain/exceptions/port-not-found.exception.ts +++ b/apps/backend/src/domain/exceptions/port-not-found.exception.ts @@ -1,13 +1,13 @@ /** * PortNotFoundException * - * Thrown when a port is not found in the database + * Thrown when a port is not found in the database. */ -export class PortNotFoundException extends Error { +import { DomainException } from './domain.exception'; + +export class PortNotFoundException extends DomainException { constructor(public readonly portCode: string) { - super(`Port not found: ${portCode}`); - this.name = 'PortNotFoundException'; - Object.setPrototypeOf(this, PortNotFoundException.prototype); + super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404); } } diff --git a/apps/backend/src/domain/value-objects/index.ts b/apps/backend/src/domain/value-objects/index.ts index 1773663..2ad876c 100644 --- a/apps/backend/src/domain/value-objects/index.ts +++ b/apps/backend/src/domain/value-objects/index.ts @@ -14,3 +14,4 @@ export * from './booking-status.vo'; export * from './subscription-plan.vo'; export * from './subscription-status.vo'; export * from './license-status.vo'; +export * from './locale.vo'; diff --git a/apps/backend/src/domain/value-objects/locale.vo.ts b/apps/backend/src/domain/value-objects/locale.vo.ts new file mode 100644 index 0000000..6217932 --- /dev/null +++ b/apps/backend/src/domain/value-objects/locale.vo.ts @@ -0,0 +1,19 @@ +/** + * Locale Value Object + * + * Represents the supported UI / response languages of the platform. + */ + +export const SUPPORTED_LOCALES = ['fr', 'en'] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = 'fr'; + +export function isLocale(value: unknown): value is Locale { + return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value); +} + +export function toLocale(value: unknown, fallback: Locale = DEFAULT_LOCALE): Locale { + return isLocale(value) ? value : fallback; +} diff --git a/apps/backend/src/i18n/en/auth.json b/apps/backend/src/i18n/en/auth.json new file mode 100644 index 0000000..14b50b6 --- /dev/null +++ b/apps/backend/src/i18n/en/auth.json @@ -0,0 +1,9 @@ +{ + "LOGIN_SUCCESS": "Login successful", + "LOGOUT_SUCCESS": "Logout successful", + "REGISTER_SUCCESS": "Registration successful — please verify your email", + "PASSWORD_RESET_SENT": "If the email exists, a reset link has been sent", + "PASSWORD_RESET_SUCCESS": "Password has been reset successfully", + "EMAIL_VERIFIED": "Email verified successfully", + "VERIFICATION_EMAIL_SENT": "Verification email has been sent" +} diff --git a/apps/backend/src/i18n/en/booking.json b/apps/backend/src/i18n/en/booking.json new file mode 100644 index 0000000..e0d75e3 --- /dev/null +++ b/apps/backend/src/i18n/en/booking.json @@ -0,0 +1,10 @@ +{ + "status": { + "DRAFT": "Draft", + "CONFIRMED": "Confirmed", + "SHIPPED": "Shipped", + "DELIVERED": "Delivered", + "CANCELLED": "Cancelled", + "REJECTED": "Rejected" + } +} diff --git a/apps/backend/src/i18n/en/common.json b/apps/backend/src/i18n/en/common.json new file mode 100644 index 0000000..7310fcf --- /dev/null +++ b/apps/backend/src/i18n/en/common.json @@ -0,0 +1,5 @@ +{ + "SUCCESS": "Success", + "YES": "Yes", + "NO": "No" +} diff --git a/apps/backend/src/i18n/en/email.json b/apps/backend/src/i18n/en/email.json new file mode 100644 index 0000000..d05015f --- /dev/null +++ b/apps/backend/src/i18n/en/email.json @@ -0,0 +1,44 @@ +{ + "common": { + "greeting": "Hello {firstName}", + "footer": "The Xpeditis team", + "ignoreIfNotYou": "If you did not request this email, you can safely ignore it." + }, + "verification": { + "subject": "Verify your email", + "title": "Welcome to Xpeditis!", + "body": "Please confirm your email address by clicking the button below.", + "cta": "Verify my email" + }, + "passwordReset": { + "subject": "Reset your password", + "title": "Reset your password", + "body": "Click the button below to set a new password. This link is valid for 1 hour.", + "cta": "Reset my password" + }, + "welcome": { + "subject": "Welcome to Xpeditis, {firstName}!", + "title": "Welcome aboard!", + "body": "Your account is ready. Start searching maritime rates and bookings right away.", + "cta": "Go to dashboard" + }, + "bookingConfirmation": { + "subject": "Booking {bookingNumber} confirmed", + "title": "Booking Confirmation", + "body": "Your booking {bookingNumber} has been confirmed successfully.", + "details": "Details", + "cta": "View booking" + }, + "userInvitation": { + "subject": "You have been invited to join Xpeditis", + "title": "You have been invited", + "body": "{inviterName} has invited you to join {organizationName} on Xpeditis.", + "cta": "Accept invitation" + }, + "csvBookingRequest": { + "subject": "New booking request {bookingReference}", + "title": "New booking request", + "body": "A new booking request has been submitted. Please review the details.", + "cta": "Review booking" + } +} diff --git a/apps/backend/src/i18n/en/error.json b/apps/backend/src/i18n/en/error.json new file mode 100644 index 0000000..a1222cc --- /dev/null +++ b/apps/backend/src/i18n/en/error.json @@ -0,0 +1,23 @@ +{ + "INTERNAL_ERROR": "Internal server error", + "UNAUTHORIZED": "Authentication required", + "FORBIDDEN": "You do not have permission to perform this action", + "NOT_FOUND": "Resource not found", + "CONFLICT": "Conflict", + "RATE_LIMITED": "Too many requests — please try again later", + "PORT_NOT_FOUND": "Port not found: {portCode}", + "PORT_INVALID_CODE": "Invalid port code: {portCode}", + "USER_NOT_FOUND": "User not found", + "USER_EMAIL_TAKEN": "This email is already in use", + "USER_INACTIVE": "User account is inactive", + "USER_EMAIL_NOT_VERIFIED": "Email address not verified", + "ORGANIZATION_NOT_FOUND": "Organization not found", + "INVALID_CREDENTIALS": "Invalid email or password", + "INVALID_TOKEN": "Invalid or expired token", + "BOOKING_NOT_FOUND": "Booking {bookingNumber} not found", + "BOOKING_INVALID_STATUS": "Invalid booking status transition", + "RATE_QUOTE_NOT_FOUND": "Rate quote not found", + "RATE_QUOTE_EXPIRED": "Rate quote has expired", + "CARRIER_NOT_FOUND": "Carrier not found", + "NO_LICENSES_AVAILABLE": "No licenses available for this organization" +} diff --git a/apps/backend/src/i18n/en/notification.json b/apps/backend/src/i18n/en/notification.json new file mode 100644 index 0000000..7402d59 --- /dev/null +++ b/apps/backend/src/i18n/en/notification.json @@ -0,0 +1,30 @@ +{ + "booking": { + "created": { + "title": "Booking Created", + "message": "Your booking {bookingNumber} has been created successfully." + }, + "updated": { + "title": "Booking Updated", + "message": "Booking {bookingNumber} status changed to {status}." + }, + "confirmed": { + "title": "Booking Confirmed", + "message": "Your booking {bookingNumber} has been confirmed by the carrier." + }, + "rejected": { + "title": "Booking Rejected", + "message": "Your booking {bookingNumber} has been rejected by the carrier." + }, + "documentUploaded": { + "title": "Document Uploaded", + "message": "Document \"{documentName}\" has been uploaded for your booking." + } + }, + "system": { + "welcome": { + "title": "Welcome to Xpeditis", + "message": "Hi {firstName}, welcome aboard! Start by searching for rates." + } + } +} diff --git a/apps/backend/src/i18n/en/pdf.json b/apps/backend/src/i18n/en/pdf.json new file mode 100644 index 0000000..7f5e0f6 --- /dev/null +++ b/apps/backend/src/i18n/en/pdf.json @@ -0,0 +1,36 @@ +{ + "booking": { + "title": "BOOKING CONFIRMATION", + "bookingNumber": "Booking Number", + "routeInformation": "Route Information", + "origin": "Origin", + "destination": "Destination", + "shipperInformation": "Shipper Information", + "consigneeInformation": "Consignee Information", + "containerDetails": "Container Details", + "cargoDescription": "Cargo Description", + "totalPrice": "Total Price", + "estimatedDeparture": "Estimated Departure", + "estimatedArrival": "Estimated Arrival", + "carrier": "Carrier", + "status": "Status" + }, + "rateQuote": { + "title": "RATE QUOTE COMPARISON", + "quoteNumber": "Quote Number", + "issuedAt": "Issued At", + "validUntil": "Valid Until", + "origin": "Origin", + "destination": "Destination", + "carrier": "Carrier", + "transitTime": "Transit Time", + "containerType": "Container Type", + "baseRate": "Base Rate", + "surcharges": "Surcharges", + "totalPrice": "Total Price" + }, + "common": { + "generatedOn": "Generated on {date}", + "page": "Page {current} of {total}" + } +} diff --git a/apps/backend/src/i18n/en/validation.json b/apps/backend/src/i18n/en/validation.json new file mode 100644 index 0000000..5fe9995 --- /dev/null +++ b/apps/backend/src/i18n/en/validation.json @@ -0,0 +1,30 @@ +{ + "EMAIL_REQUIRED": "Email is required", + "EMAIL_INVALID": "Invalid email format", + "PASSWORD_REQUIRED": "Password is required", + "PASSWORD_MIN_LENGTH": "Password must be at least {constraint1} characters", + "PASSWORD_MAX_LENGTH": "Password must be at most {constraint1} characters", + "PASSWORD_PATTERN": "Password must contain uppercase, lowercase, number and special character", + "FIRST_NAME_REQUIRED": "First name is required", + "FIRST_NAME_MIN_LENGTH": "First name must be at least {constraint1} characters", + "LAST_NAME_REQUIRED": "Last name is required", + "LAST_NAME_MIN_LENGTH": "Last name must be at least {constraint1} characters", + "PHONE_INVALID": "Invalid phone number", + "SIREN_PATTERN": "SIREN must be exactly 9 digits", + "SIRET_PATTERN": "SIRET must be exactly 14 digits", + "STREET_MIN_LENGTH": "Street must be at least {constraint1} characters", + "CITY_REQUIRED": "City is required", + "POSTAL_CODE_REQUIRED": "Postal code is required", + "COUNTRY_PATTERN": "Country must be a 2-letter ISO code (e.g., FR, US, CN)", + "FIELD_REQUIRED": "This field is required", + "FIELD_TOO_SHORT": "Must be at least {constraint1} characters", + "FIELD_TOO_LONG": "Must be at most {constraint1} characters", + "NUMBER_MIN": "Must be at least {constraint1}", + "NUMBER_MAX": "Must be at most {constraint1}", + "INVALID_UUID": "Invalid identifier format", + "INVALID_DATE": "Invalid date", + "INVALID_ENUM": "Invalid value — allowed values: {constraint1}", + "INVALID_BOOLEAN": "Must be true or false", + "INVALID_URL": "Invalid URL", + "LOCALE_INVALID": "Language must be 'fr' or 'en'" +} diff --git a/apps/backend/src/i18n/fr/auth.json b/apps/backend/src/i18n/fr/auth.json new file mode 100644 index 0000000..7f45ec5 --- /dev/null +++ b/apps/backend/src/i18n/fr/auth.json @@ -0,0 +1,9 @@ +{ + "LOGIN_SUCCESS": "Connexion réussie", + "LOGOUT_SUCCESS": "Déconnexion réussie", + "REGISTER_SUCCESS": "Inscription réussie — veuillez vérifier votre email", + "PASSWORD_RESET_SENT": "Si l'email existe, un lien de réinitialisation a été envoyé", + "PASSWORD_RESET_SUCCESS": "Mot de passe réinitialisé avec succès", + "EMAIL_VERIFIED": "Email vérifié avec succès", + "VERIFICATION_EMAIL_SENT": "Email de vérification envoyé" +} diff --git a/apps/backend/src/i18n/fr/booking.json b/apps/backend/src/i18n/fr/booking.json new file mode 100644 index 0000000..131ae79 --- /dev/null +++ b/apps/backend/src/i18n/fr/booking.json @@ -0,0 +1,10 @@ +{ + "status": { + "DRAFT": "Brouillon", + "CONFIRMED": "Confirmée", + "SHIPPED": "Expédiée", + "DELIVERED": "Livrée", + "CANCELLED": "Annulée", + "REJECTED": "Refusée" + } +} diff --git a/apps/backend/src/i18n/fr/common.json b/apps/backend/src/i18n/fr/common.json new file mode 100644 index 0000000..df67e65 --- /dev/null +++ b/apps/backend/src/i18n/fr/common.json @@ -0,0 +1,5 @@ +{ + "SUCCESS": "Succès", + "YES": "Oui", + "NO": "Non" +} diff --git a/apps/backend/src/i18n/fr/email.json b/apps/backend/src/i18n/fr/email.json new file mode 100644 index 0000000..dafcf10 --- /dev/null +++ b/apps/backend/src/i18n/fr/email.json @@ -0,0 +1,44 @@ +{ + "common": { + "greeting": "Bonjour {firstName}", + "footer": "L'équipe Xpeditis", + "ignoreIfNotYou": "Si vous n'êtes pas à l'origine de cet email, vous pouvez l'ignorer." + }, + "verification": { + "subject": "Vérifiez votre email", + "title": "Bienvenue sur Xpeditis !", + "body": "Veuillez confirmer votre adresse email en cliquant sur le bouton ci-dessous.", + "cta": "Vérifier mon email" + }, + "passwordReset": { + "subject": "Réinitialisez votre mot de passe", + "title": "Réinitialisez votre mot de passe", + "body": "Cliquez sur le bouton ci-dessous pour définir un nouveau mot de passe. Ce lien est valide 1 heure.", + "cta": "Réinitialiser mon mot de passe" + }, + "welcome": { + "subject": "Bienvenue sur Xpeditis, {firstName} !", + "title": "Bienvenue à bord !", + "body": "Votre compte est prêt. Commencez dès maintenant à rechercher des tarifs maritimes et à réserver.", + "cta": "Accéder au tableau de bord" + }, + "bookingConfirmation": { + "subject": "Réservation {bookingNumber} confirmée", + "title": "Confirmation de réservation", + "body": "Votre réservation {bookingNumber} a été confirmée avec succès.", + "details": "Détails", + "cta": "Voir la réservation" + }, + "userInvitation": { + "subject": "Vous avez été invité à rejoindre Xpeditis", + "title": "Vous avez été invité", + "body": "{inviterName} vous invite à rejoindre {organizationName} sur Xpeditis.", + "cta": "Accepter l'invitation" + }, + "csvBookingRequest": { + "subject": "Nouvelle demande de réservation {bookingReference}", + "title": "Nouvelle demande de réservation", + "body": "Une nouvelle demande de réservation vous est soumise. Veuillez examiner les détails.", + "cta": "Examiner la réservation" + } +} diff --git a/apps/backend/src/i18n/fr/error.json b/apps/backend/src/i18n/fr/error.json new file mode 100644 index 0000000..f0e76e6 --- /dev/null +++ b/apps/backend/src/i18n/fr/error.json @@ -0,0 +1,23 @@ +{ + "INTERNAL_ERROR": "Erreur interne du serveur", + "UNAUTHORIZED": "Authentification requise", + "FORBIDDEN": "Vous n'avez pas la permission d'effectuer cette action", + "NOT_FOUND": "Ressource introuvable", + "CONFLICT": "Conflit", + "RATE_LIMITED": "Trop de requêtes — veuillez réessayer plus tard", + "PORT_NOT_FOUND": "Port introuvable : {portCode}", + "PORT_INVALID_CODE": "Code de port invalide : {portCode}", + "USER_NOT_FOUND": "Utilisateur introuvable", + "USER_EMAIL_TAKEN": "Cet email est déjà utilisé", + "USER_INACTIVE": "Le compte utilisateur est inactif", + "USER_EMAIL_NOT_VERIFIED": "Adresse email non vérifiée", + "ORGANIZATION_NOT_FOUND": "Organisation introuvable", + "INVALID_CREDENTIALS": "Email ou mot de passe invalide", + "INVALID_TOKEN": "Jeton invalide ou expiré", + "BOOKING_NOT_FOUND": "Réservation {bookingNumber} introuvable", + "BOOKING_INVALID_STATUS": "Transition de statut de réservation invalide", + "RATE_QUOTE_NOT_FOUND": "Cotation introuvable", + "RATE_QUOTE_EXPIRED": "La cotation a expiré", + "CARRIER_NOT_FOUND": "Transporteur introuvable", + "NO_LICENSES_AVAILABLE": "Aucune licence disponible pour cette organisation" +} diff --git a/apps/backend/src/i18n/fr/notification.json b/apps/backend/src/i18n/fr/notification.json new file mode 100644 index 0000000..52189bf --- /dev/null +++ b/apps/backend/src/i18n/fr/notification.json @@ -0,0 +1,30 @@ +{ + "booking": { + "created": { + "title": "Réservation créée", + "message": "Votre réservation {bookingNumber} a été créée avec succès." + }, + "updated": { + "title": "Réservation mise à jour", + "message": "Le statut de la réservation {bookingNumber} est passé à {status}." + }, + "confirmed": { + "title": "Réservation confirmée", + "message": "Votre réservation {bookingNumber} a été confirmée par le transporteur." + }, + "rejected": { + "title": "Réservation refusée", + "message": "Votre réservation {bookingNumber} a été refusée par le transporteur." + }, + "documentUploaded": { + "title": "Document ajouté", + "message": "Le document « {documentName} » a été ajouté à votre réservation." + } + }, + "system": { + "welcome": { + "title": "Bienvenue sur Xpeditis", + "message": "Bonjour {firstName}, bienvenue à bord ! Commencez par rechercher des tarifs." + } + } +} diff --git a/apps/backend/src/i18n/fr/pdf.json b/apps/backend/src/i18n/fr/pdf.json new file mode 100644 index 0000000..9d9d621 --- /dev/null +++ b/apps/backend/src/i18n/fr/pdf.json @@ -0,0 +1,36 @@ +{ + "booking": { + "title": "CONFIRMATION DE RÉSERVATION", + "bookingNumber": "Numéro de réservation", + "routeInformation": "Informations de route", + "origin": "Origine", + "destination": "Destination", + "shipperInformation": "Expéditeur", + "consigneeInformation": "Destinataire", + "containerDetails": "Détails du conteneur", + "cargoDescription": "Description de la cargaison", + "totalPrice": "Prix total", + "estimatedDeparture": "Départ estimé", + "estimatedArrival": "Arrivée estimée", + "carrier": "Transporteur", + "status": "Statut" + }, + "rateQuote": { + "title": "COMPARAISON DE COTATIONS", + "quoteNumber": "Numéro de cotation", + "issuedAt": "Émis le", + "validUntil": "Valide jusqu'au", + "origin": "Origine", + "destination": "Destination", + "carrier": "Transporteur", + "transitTime": "Temps de transit", + "containerType": "Type de conteneur", + "baseRate": "Tarif de base", + "surcharges": "Surtaxes", + "totalPrice": "Prix total" + }, + "common": { + "generatedOn": "Généré le {date}", + "page": "Page {current} sur {total}" + } +} diff --git a/apps/backend/src/i18n/fr/validation.json b/apps/backend/src/i18n/fr/validation.json new file mode 100644 index 0000000..f6e63ff --- /dev/null +++ b/apps/backend/src/i18n/fr/validation.json @@ -0,0 +1,30 @@ +{ + "EMAIL_REQUIRED": "L'email est requis", + "EMAIL_INVALID": "Format d'email invalide", + "PASSWORD_REQUIRED": "Le mot de passe est requis", + "PASSWORD_MIN_LENGTH": "Le mot de passe doit contenir au moins {constraint1} caractères", + "PASSWORD_MAX_LENGTH": "Le mot de passe doit contenir au plus {constraint1} caractères", + "PASSWORD_PATTERN": "Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial", + "FIRST_NAME_REQUIRED": "Le prénom est requis", + "FIRST_NAME_MIN_LENGTH": "Le prénom doit contenir au moins {constraint1} caractères", + "LAST_NAME_REQUIRED": "Le nom est requis", + "LAST_NAME_MIN_LENGTH": "Le nom doit contenir au moins {constraint1} caractères", + "PHONE_INVALID": "Numéro de téléphone invalide", + "SIREN_PATTERN": "Le SIREN doit contenir exactement 9 chiffres", + "SIRET_PATTERN": "Le SIRET doit contenir exactement 14 chiffres", + "STREET_MIN_LENGTH": "L'adresse doit contenir au moins {constraint1} caractères", + "CITY_REQUIRED": "La ville est requise", + "POSTAL_CODE_REQUIRED": "Le code postal est requis", + "COUNTRY_PATTERN": "Le pays doit être un code ISO à 2 lettres (ex. FR, US, CN)", + "FIELD_REQUIRED": "Ce champ est requis", + "FIELD_TOO_SHORT": "Doit contenir au moins {constraint1} caractères", + "FIELD_TOO_LONG": "Doit contenir au plus {constraint1} caractères", + "NUMBER_MIN": "Doit être supérieur ou égal à {constraint1}", + "NUMBER_MAX": "Doit être inférieur ou égal à {constraint1}", + "INVALID_UUID": "Format d'identifiant invalide", + "INVALID_DATE": "Date invalide", + "INVALID_ENUM": "Valeur invalide — valeurs autorisées : {constraint1}", + "INVALID_BOOLEAN": "Doit être vrai ou faux", + "INVALID_URL": "URL invalide", + "LOCALE_INVALID": "La langue doit être 'fr' ou 'en'" +} diff --git a/apps/backend/src/infrastructure/email/email.adapter.ts b/apps/backend/src/infrastructure/email/email.adapter.ts index 78501d1..5102d0f 100644 --- a/apps/backend/src/infrastructure/email/email.adapter.ts +++ b/apps/backend/src/infrastructure/email/email.adapter.ts @@ -73,7 +73,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit { this.buildTransporter(ip, host); return; } catch (err: any) { - this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`); + this.logger.warn( + `[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly` + ); } } @@ -87,9 +89,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit { private resolveViaDoH(hostname: string): Promise { return new Promise((resolve, reject) => { const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`; - const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, (res) => { + const req = https.get(url, { headers: { Accept: 'application/dns-json' } }, res => { let raw = ''; - res.on('data', (chunk) => (raw += chunk)); + res.on('data', chunk => (raw += chunk)); res.on('end', () => { try { const json = JSON.parse(raw); @@ -136,7 +138,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit { `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}` ); - this.transporter.verify((error) => { + this.transporter.verify(error => { if (error) { this.logger.error(`❌ SMTP connection FAILED: ${error.message}`); } else { @@ -148,8 +150,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit { async send(options: EmailOptions): Promise { try { const from = - options.from ?? - this.configService.get('SMTP_FROM', EMAIL_SENDERS.NOREPLY); + options.from ?? this.configService.get('SMTP_FROM', EMAIL_SENDERS.NOREPLY); // Génère automatiquement la version plain text si absente (améliore le score anti-spam) const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined); diff --git a/apps/backend/src/infrastructure/i18n/user-preference.resolver.ts b/apps/backend/src/infrastructure/i18n/user-preference.resolver.ts new file mode 100644 index 0000000..6171958 --- /dev/null +++ b/apps/backend/src/infrastructure/i18n/user-preference.resolver.ts @@ -0,0 +1,19 @@ +/** + * UserPreferenceResolver + * + * nestjs-i18n resolver that reads the authenticated user's preferredLanguage + * from the request (populated by JwtAuthGuard). Highest priority in the chain. + */ + +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { I18nResolver } from 'nestjs-i18n'; +import { isLocale } from '@domain/value-objects/locale.vo'; + +@Injectable() +export class UserPreferenceResolver implements I18nResolver { + resolve(context: ExecutionContext): string | undefined { + const request = context.switchToHttp().getRequest(); + const preferred = request?.user?.preferredLanguage; + return isLocale(preferred) ? preferred : undefined; + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index 75eb591..19ae980 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -75,11 +75,24 @@ export class CsvBookingOrmEntity { @Column({ name: 'status', type: 'enum', - enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + enum: [ + 'PENDING_PAYMENT', + 'PENDING_BANK_TRANSFER', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED', + ], default: 'PENDING_PAYMENT', }) @Index() - status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; + status: + | 'PENDING_PAYMENT' + | 'PENDING_BANK_TRANSFER' + | 'PENDING' + | 'ACCEPTED' + | 'REJECTED' + | 'CANCELLED'; @Column({ name: 'documents', type: 'jsonb' }) documents: Array<{ diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts index 9c59b49..cab1e12 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/organization.orm-entity.ts @@ -74,6 +74,9 @@ export class OrganizationOrmEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive: boolean; + @Column({ name: 'default_language', type: 'varchar', length: 2, default: 'fr' }) + defaultLanguage: string; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts index fd4598f..ad99176 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/password-reset-token.orm-entity.ts @@ -1,10 +1,4 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - Index, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; @Entity('password_reset_tokens') export class PasswordResetTokenOrmEntity { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts index 7946aba..eb65349 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/user.orm-entity.ts @@ -62,6 +62,9 @@ export class UserOrmEntity { @Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) lastLoginAt: Date | null; + @Column({ name: 'preferred_language', type: 'varchar', length: 2, default: 'fr' }) + preferredLanguage: string; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts index 9eb59c6..e119e0f 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/organization-orm.mapper.ts @@ -5,6 +5,7 @@ */ import { Organization, OrganizationProps } from '@domain/entities/organization.entity'; +import { toLocale } from '@domain/value-objects/locale.vo'; import { OrganizationOrmEntity } from '../entities/organization.orm-entity'; export class OrganizationOrmMapper { @@ -34,6 +35,7 @@ export class OrganizationOrmMapper { orm.siretVerified = props.siretVerified; orm.statusBadge = props.statusBadge; orm.isActive = props.isActive; + orm.defaultLanguage = props.defaultLanguage; orm.createdAt = props.createdAt; orm.updatedAt = props.updatedAt; @@ -66,6 +68,7 @@ export class OrganizationOrmMapper { siretVerified: orm.siretVerified ?? false, statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none', isActive: orm.isActive, + defaultLanguage: toLocale(orm.defaultLanguage), createdAt: orm.createdAt, updatedAt: orm.updatedAt, }; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts index bfc2d1c..591f9db 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/subscription-orm.mapper.ts @@ -5,7 +5,10 @@ */ import { Subscription } from '@domain/entities/subscription.entity'; -import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity'; +import { + SubscriptionOrmEntity, + SubscriptionPlanOrmType, +} from '../entities/subscription.orm-entity'; /** Maps canonical domain plan names back to the values stored in the DB. */ const DOMAIN_TO_ORM_PLAN: Record = { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts b/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts index 1e9d4b0..019cf85 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/mappers/user-orm.mapper.ts @@ -5,6 +5,7 @@ */ import { User, UserProps } from '@domain/entities/user.entity'; +import { toLocale } from '@domain/value-objects/locale.vo'; import { UserOrmEntity } from '../entities/user.orm-entity'; export class UserOrmMapper { @@ -27,6 +28,7 @@ export class UserOrmMapper { orm.isEmailVerified = props.isEmailVerified; orm.isActive = props.isActive; orm.lastLoginAt = props.lastLoginAt || null; + orm.preferredLanguage = props.preferredLanguage; orm.createdAt = props.createdAt; orm.updatedAt = props.updatedAt; @@ -50,6 +52,7 @@ export class UserOrmMapper { isEmailVerified: orm.isEmailVerified, isActive: orm.isActive, lastLoginAt: orm.lastLoginAt || undefined, + preferredLanguage: toLocale(orm.preferredLanguage), createdAt: orm.createdAt, updatedAt: orm.updatedAt, }; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts index c443352..ce871c1 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1741000000001-CreateApiKeysTable.ts @@ -38,15 +38,9 @@ export class CreateApiKeysTable1741000000001 implements MigrationInterface { await queryRunner.query( `CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")` ); - await queryRunner.query( - `CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")` - ); - await queryRunner.query( - `CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")` - ); - await queryRunner.query( - `CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")` - ); + await queryRunner.query(`CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`); + await queryRunner.query(`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`); + await queryRunner.query(`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`); await queryRunner.query( `COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'` diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1745000000000-AddPreferredLanguage.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1745000000000-AddPreferredLanguage.ts new file mode 100644 index 0000000..9042aa2 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1745000000000-AddPreferredLanguage.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPreferredLanguage1745000000000 implements MigrationInterface { + name = 'AddPreferredLanguage1745000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "users" + ADD COLUMN "preferred_language" VARCHAR(2) NOT NULL DEFAULT 'fr' + `); + await queryRunner.query(` + ALTER TABLE "organizations" + ADD COLUMN "default_language" VARCHAR(2) NOT NULL DEFAULT 'fr' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "organizations" DROP COLUMN "default_language" + `); + await queryRunner.query(` + ALTER TABLE "users" DROP COLUMN "preferred_language" + `); + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 657f47a..c562168 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,12 +1,14 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { VersioningType } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; +import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n'; import helmet from 'helmet'; import compression from 'compression'; import { AppModule } from './app.module'; import { Logger } from 'nestjs-pino'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; +import { DomainExceptionFilter } from './application/filters/domain-exception.filter'; import type { Request, Response, NextFunction } from 'express'; async function bootstrap() { @@ -42,9 +44,9 @@ async function bootstrap() { type: VersioningType.URI, }); - // Global validation pipe + // Global validation pipe — i18n-aware (messages translated to caller locale) app.useGlobalPipes( - new ValidationPipe({ + new I18nValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, @@ -54,6 +56,15 @@ async function bootstrap() { }) ); + // Global exception filters — each filter declares its target via @Catch(), + // so they don't overlap: DomainExceptionFilter handles DomainException, + // I18nValidationExceptionFilter handles class-validator errors. + const i18nService = app.get(I18nService) as I18nService>; + app.useGlobalFilters( + new DomainExceptionFilter(i18nService), + new I18nValidationExceptionFilter({ detailedErrors: false }) + ); + // ─── Swagger documentation ──────────────────────────────────────────────── const swaggerUser = configService.get('SWAGGER_USERNAME'); const swaggerPass = configService.get('SWAGGER_PASSWORD'); diff --git a/apps/frontend/app/about/page.tsx b/apps/frontend/app/[locale]/about/page.tsx similarity index 69% rename from apps/frontend/app/about/page.tsx rename to apps/frontend/app/[locale]/about/page.tsx index b89b7c0..e6a4ee3 100644 --- a/apps/frontend/app/about/page.tsx +++ b/apps/frontend/app/[locale]/about/page.tsx @@ -1,494 +1,407 @@ -'use client'; - -import { useRef } from 'react'; -import Link from 'next/link'; -import { motion, useInView } from 'framer-motion'; -import { - Ship, - Target, - Eye, - Heart, - Users, - TrendingUp, - Linkedin, - Calendar, - ArrowRight, -} from 'lucide-react'; -import { LandingHeader, LandingFooter } from '@/components/layout'; - -export default function AboutPage() { - const heroRef = useRef(null); - const missionRef = useRef(null); - const valuesRef = useRef(null); - const teamRef = useRef(null); - const timelineRef = useRef(null); - const statsRef = useRef(null); - - const isHeroInView = useInView(heroRef, { once: true }); - const isMissionInView = useInView(missionRef, { once: true }); - const isValuesInView = useInView(valuesRef, { once: true }); - const isTeamInView = useInView(teamRef, { once: true }); - const isTimelineInView = useInView(timelineRef, { once: true }); - const isStatsInView = useInView(statsRef, { once: true }); - - const values = [ - { - icon: Target, - title: 'Excellence', - description: - 'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.', - color: 'from-blue-500 to-cyan-500', - }, - { - icon: Heart, - title: 'Transparence', - description: - 'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.', - color: 'from-pink-500 to-rose-500', - }, - { - icon: Users, - title: 'Collaboration', - description: - 'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.', - color: 'from-purple-500 to-indigo-500', - }, - { - icon: TrendingUp, - title: 'Innovation', - description: - 'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.', - color: 'from-orange-500 to-amber-500', - }, - ]; - - const team = [ - { - name: 'Jean-Pierre Durand', - role: 'CEO & Co-fondateur', - bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping', - image: '/assets/images/team/ceo.jpg', - linkedin: '#', - }, - { - name: 'Marie Lefebvre', - role: 'CTO & Co-fondatrice', - bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués', - image: '/assets/images/team/cto.jpg', - linkedin: '#', - }, - { - name: 'Thomas Martin', - role: 'COO', - bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales', - image: '/assets/images/team/coo.jpg', - linkedin: '#', - }, - { - name: 'Sophie Bernard', - role: 'VP Sales', - bio: '15 ans d\'expérience commerciale dans le secteur logistique', - image: '/assets/images/team/vp-sales.jpg', - linkedin: '#', - }, - { - name: 'Alexandre Petit', - role: 'VP Engineering', - bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel', - image: '/assets/images/team/vp-eng.jpg', - linkedin: '#', - }, - { - name: 'Claire Moreau', - role: 'VP Product', - bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit', - image: '/assets/images/team/vp-product.jpg', - linkedin: '#', - }, - ]; - - const timeline = [ - { - year: '2021', - title: 'Fondation', - description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.', - }, - { - year: '2022', - title: 'Première version', - description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.', - }, - { - year: '2023', - title: 'Série A', - description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.', - }, - { - year: '2024', - title: 'Expansion', - description: '50+ compagnies maritimes, présence dans 15 pays européens.', - }, - { - year: '2025', - title: 'Leader européen', - description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.', - }, - ]; - - const stats = [ - { value: '500+', label: 'Clients actifs' }, - { value: '50+', label: 'Compagnies maritimes' }, - { value: '15', label: 'Pays couverts' }, - { value: '100K+', label: 'Réservations/an' }, - ]; - - 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 }, - }, - }; - - return ( -
- - - {/* Hero Section */} -
-
-
-
-
- -
- - - - Notre histoire - - -

- Révolutionner le fret maritime, -
- - une réservation à la fois - -

- -

- Fondée en 2021, Xpeditis est née d'une vision simple : rendre le fret maritime aussi simple - qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus - grandes compagnies maritimes. -

-
-
- - {/* Wave */} -
- - - -
-
- - {/* Mission & Vision Section */} -
-
- - -
- -
-

Notre Mission

-

- Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe - qui simplifie la recherche, la comparaison et la réservation de transport maritime pour - tous les professionnels de la logistique. -

-
- - -
- -
-

Notre Vision

-

- Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire - à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité - que mérite le commerce international. -

-
-
-
-
- - {/* Stats Section */} -
- -
- {stats.map((stat, index) => ( - - - {stat.value} - -
{stat.label}
-
- ))} -
-
-
- - {/* Values Section */} -
-
- -

Nos Valeurs

-

- Les principes qui guident chacune de nos décisions -

-
- - - {values.map((value, index) => { - const IconComponent = value.icon; - return ( - -
- -
-

{value.title}

-

{value.description}

-
- ); - })} -
-
-
- - {/* Timeline Section */} -
-
- -

Notre Parcours

-

- De la startup au leader européen du fret maritime digital -

-
- -
- {/* Timeline vertical rail + animated fill */} -
- -
- -
- {timeline.map((item, index) => ( - -
-
-
- - {item.year} -
-

{item.title}

-

{item.description}

-
-
- - {/* Animated center dot */} -
- -
- -
- - ))} -
-
-
-
- - {/* Team Section */} -
-
- -

Notre Équipe

-

- Des experts passionnés par le maritime et la technologie -

-
- - - {team.map((member, index) => ( - -
-
- -
-
- - - -
-
-
-

{member.name}

-

{member.role}

-

{member.bio}

-
-
- ))} -
-
-
- - {/* CTA Section */} -
-
- -

- Rejoignez l'aventure Xpeditis -

-

- Que vous soyez transitaire à la recherche d'une solution moderne ou talent souhaitant - rejoindre une équipe passionnée, nous avons hâte de vous rencontrer. -

-
- - Créer un compte - - - - Voir les offres d'emploi - -
-
-
-
- - -
- ); -} +'use client'; + +import { useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { motion, useInView } from 'framer-motion'; +import { + Ship, + Target, + Eye, + Heart, + Users, + TrendingUp, + Linkedin, + Calendar, + ArrowRight, + type LucideIcon, +} from 'lucide-react'; +import { LandingHeader, LandingFooter } from '@/components/layout'; + +type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation'; +type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct'; +type TimelineKey = '2021' | '2022' | '2023' | '2024' | '2025'; +type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings'; + +const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [ + { key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' }, + { key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' }, + { key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' }, + { key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' }, +]; + +const TEAM: { key: TeamKey; name: string; linkedin: string }[] = [ + { key: 'ceo', name: 'Jean-Pierre Durand', linkedin: '#' }, + { key: 'cto', name: 'Marie Lefebvre', linkedin: '#' }, + { key: 'coo', name: 'Thomas Martin', linkedin: '#' }, + { key: 'vpSales', name: 'Sophie Bernard', linkedin: '#' }, + { key: 'vpEng', name: 'Alexandre Petit', linkedin: '#' }, + { key: 'vpProduct', name: 'Claire Moreau', linkedin: '#' }, +]; + +const TIMELINE_YEARS: TimelineKey[] = ['2021', '2022', '2023', '2024', '2025']; + +const STATS: { key: StatKey; value: string }[] = [ + { key: 'clients', value: '500+' }, + { key: 'carriers', value: '50+' }, + { key: 'countries', value: '15' }, + { key: 'bookings', value: '100K+' }, +]; + +export default function AboutPage() { + const t = useTranslations('marketing.about'); + const heroRef = useRef(null); + const missionRef = useRef(null); + const valuesRef = useRef(null); + const teamRef = useRef(null); + const timelineRef = useRef(null); + const statsRef = useRef(null); + + const isHeroInView = useInView(heroRef, { once: true }); + const isMissionInView = useInView(missionRef, { once: true }); + const isValuesInView = useInView(valuesRef, { once: true }); + const isTeamInView = useInView(teamRef, { once: true }); + const isTimelineInView = useInView(timelineRef, { once: true }); + const isStatsInView = useInView(statsRef, { once: true }); + + 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 }, + }, + }; + + return ( +
+ + + {/* Hero Section */} +
+
+
+
+
+ +
+ + + + {t('badge')} + + +

+ {t('title1')} +
+ + {t('title2')} + +

+ +

+ {t('intro')} +

+
+
+ + {/* Wave */} +
+ + + +
+
+ + {/* Mission & Vision Section */} +
+
+ + +
+ +
+

{t('mission.title')}

+

+ {t('mission.body')} +

+
+ + +
+ +
+

{t('vision.title')}

+

+ {t('vision.body')} +

+
+
+
+
+ + {/* Stats Section */} +
+ +
+ {STATS.map((stat, index) => ( + + + {stat.value} + +
{t(`stats.${stat.key}`)}
+
+ ))} +
+
+
+ + {/* Values Section */} +
+
+ +

{t('valuesTitle')}

+

+ {t('valuesSubtitle')} +

+
+ + + {VALUES.map((value) => { + const IconComponent = value.icon; + return ( + +
+ +
+

{t(`values.${value.key}.title`)}

+

{t(`values.${value.key}.description`)}

+
+ ); + })} +
+
+
+ + {/* Timeline Section */} +
+
+ +

{t('timelineTitle')}

+

+ {t('timelineSubtitle')} +

+
+ +
+
+ +
+ +
+ {TIMELINE_YEARS.map((year, index) => ( + +
+
+
+ + {year} +
+

{t(`timeline.${year}.title`)}

+

{t(`timeline.${year}.description`)}

+
+
+ +
+ +
+ +
+ + ))} +
+
+
+
+ + {/* Team Section */} +
+
+ +

{t('teamTitle')}

+

+ {t('teamSubtitle')} +

+
+ + + {TEAM.map((member) => ( + +
+
+ +
+
+ + + +
+
+
+

{member.name}

+

{t(`team.${member.key}.role`)}

+

{t(`team.${member.key}.bio`)}

+
+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+ +

+ {t('cta.title')} +

+

+ {t('cta.body')} +

+
+ + {t('cta.createAccount')} + + + + {t('cta.viewCareers')} + +
+
+
+
+ + +
+ ); +} diff --git a/apps/frontend/app/blog/page.tsx b/apps/frontend/app/[locale]/blog/page.tsx similarity index 65% rename from apps/frontend/app/blog/page.tsx rename to apps/frontend/app/[locale]/blog/page.tsx index 530cb4b..9c4e18a 100644 --- a/apps/frontend/app/blog/page.tsx +++ b/apps/frontend/app/[locale]/blog/page.tsx @@ -1,473 +1,390 @@ -'use client'; - -import { useState, useRef } from 'react'; -import Link from 'next/link'; -import { motion, useInView } from 'framer-motion'; -import { - Ship, - BookOpen, - Calendar, - Clock, - User, - ArrowRight, - Search, - TrendingUp, - Globe, - FileText, - Anchor, -} from 'lucide-react'; -import { LandingHeader, LandingFooter } from '@/components/layout'; - -export default function BlogPage() { - const [selectedCategory, setSelectedCategory] = useState('all'); - const [searchQuery, setSearchQuery] = useState(''); - - const heroRef = useRef(null); - const articlesRef = useRef(null); - const categoriesRef = useRef(null); - - const isHeroInView = useInView(heroRef, { once: true }); - const isArticlesInView = useInView(articlesRef, { once: true }); - const isCategoriesInView = useInView(categoriesRef, { once: true }); - - const categories = [ - { value: 'all', label: 'Tous les articles', icon: BookOpen }, - { value: 'industry', label: 'Industrie maritime', icon: Ship }, - { value: 'technology', label: 'Technologie', icon: TrendingUp }, - { value: 'guides', label: 'Guides pratiques', icon: FileText }, - { value: 'news', label: 'Actualités', icon: Globe }, - ]; - - const featuredArticle = { - id: 1, - title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique', - excerpt: - 'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.', - category: 'technology', - author: 'Marie Lefebvre', - authorRole: 'CTO', - date: '15 janvier 2025', - readTime: '8 min', - image: '/assets/images/blog/featured.jpg', - tags: ['IA', 'Innovation', 'Logistique'], - }; - - const articles = [ - { - id: 2, - title: 'Guide complet des Incoterms 2020 pour le transport maritime', - excerpt: - 'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.', - category: 'guides', - author: 'Thomas Martin', - date: '10 janvier 2025', - readTime: '12 min', - image: '/assets/images/blog/incoterms.jpg', - tags: ['Incoterms', 'Guide', 'Commerce international'], - }, - { - id: 3, - title: 'Comment optimiser vos coûts de transport maritime en 2025', - excerpt: - 'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.', - category: 'guides', - author: 'Sophie Bernard', - date: '8 janvier 2025', - readTime: '6 min', - image: '/assets/images/blog/costs.jpg', - tags: ['Optimisation', 'Coûts', 'Stratégie'], - }, - { - id: 4, - title: 'Les plus grands ports européens : classement 2025', - excerpt: - 'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.', - category: 'industry', - author: 'Jean-Pierre Durand', - date: '5 janvier 2025', - readTime: '10 min', - image: '/assets/images/blog/ports.jpg', - tags: ['Ports', 'Europe', 'Statistiques'], - }, - { - id: 5, - title: 'Xpeditis lève 15M€ pour accélérer son expansion', - excerpt: - 'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.', - category: 'news', - author: 'Jean-Pierre Durand', - date: '3 janvier 2025', - readTime: '4 min', - image: '/assets/images/blog/funding.jpg', - tags: ['Financement', 'Croissance', 'Xpeditis'], - }, - { - id: 6, - title: 'Décarbonation du transport maritime : où en sommes-nous ?', - excerpt: - 'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.', - category: 'industry', - author: 'Claire Moreau', - date: '28 décembre 2024', - readTime: '9 min', - image: '/assets/images/blog/green.jpg', - tags: ['Environnement', 'Décarbonation', 'Durabilité'], - }, - { - id: 7, - title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis', - excerpt: - 'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.', - category: 'technology', - author: 'Alexandre Petit', - date: '22 décembre 2024', - readTime: '15 min', - image: '/assets/images/blog/api.jpg', - tags: ['API', 'Intégration', 'Technique'], - }, - { - id: 8, - title: 'Les documents essentiels pour l\'export maritime', - excerpt: - 'Check-list complète des documents requis pour vos expéditions maritimes internationales.', - category: 'guides', - author: 'Thomas Martin', - date: '18 décembre 2024', - readTime: '7 min', - image: '/assets/images/blog/documents.jpg', - tags: ['Documents', 'Export', 'Douane'], - }, - ]; - - const filteredArticles = articles.filter((article) => { - const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; - const searchMatch = - searchQuery === '' || - article.title.toLowerCase().includes(searchQuery.toLowerCase()) || - article.excerpt.toLowerCase().includes(searchQuery.toLowerCase()); - return categoryMatch && searchMatch; - }); - - 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 }, - }, - }; - - return ( -
- - - {/* Hero Section */} -
-
-
-
-
- -
- - - - Blog Xpeditis - - -

- Actualités & Insights -
- - du fret maritime - -

- -

- Restez informé des dernières tendances du transport maritime, découvrez nos guides - pratiques et suivez l'actualité de Xpeditis. -

- - {/* Search Bar */} - -
- - 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" - /> -
-
-
-
- - {/* Wave */} -
- - - -
-
- - {/* Categories */} -
- -
- {categories.map((category) => { - const IconComponent = category.icon; - const isActive = selectedCategory === category.value; - return ( - - ); - })} -
-
-
- - {/* Featured Article */} -
-
- - -
-
-
- -
- -
-
-
- - À la une - - - {categories.find((c) => c.value === featuredArticle.category)?.label} - -
- -

- {featuredArticle.title} -

- -

{featuredArticle.excerpt}

- -
-
- - {featuredArticle.author} -
-
- - {featuredArticle.date} -
-
- - {featuredArticle.readTime} -
-
- -
- Lire l'article - -
-
-
-
- - -
-
- - {/* Articles Grid */} -
-
- -

Tous les articles

- {filteredArticles.length} articles -
- - {filteredArticles.length === 0 ? ( -
- -

Aucun article trouvé

-

Essayez de modifier vos filtres ou votre recherche

-
- ) : ( - - {filteredArticles.map((article) => ( - - -
-
- -
- - {categories.find((c) => c.value === article.category)?.label} - -
-
- -
-

- {article.title} -

- -

{article.excerpt}

- -
- {article.tags.map((tag) => ( - - {tag} - - ))} -
- -
-
-
- -
- {article.author} -
-
- {article.date} - - - {article.readTime} - -
-
-
-
- -
- ))} -
- )} - - {/* Load More */} - {filteredArticles.length > 0 && ( - - - - )} -
-
- - {/* Newsletter Section */} -
-
- -

- Restez informé -

-

- Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités - du fret maritime directement dans votre boîte mail. -

-
- - -
-

- En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment. -

-
-
-
- - -
- ); -} +'use client'; + +import { useState, useRef } from 'react'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { motion, useInView } from 'framer-motion'; +import { + Ship, + BookOpen, + Calendar, + Clock, + User, + ArrowRight, + Search, + TrendingUp, + Globe, + FileText, + Anchor, + type LucideIcon, +} from 'lucide-react'; +import { LandingHeader, LandingFooter } from '@/components/layout'; + +type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news'; +type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents'; + +const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ + { key: 'all', icon: BookOpen }, + { key: 'industry', icon: Ship }, + { key: 'technology', icon: TrendingUp }, + { key: 'guides', icon: FileText }, + { key: 'news', icon: Globe }, +]; + +const ARTICLES: { id: number; key: ArticleKey; category: Exclude; 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'] }, +]; + +export default function BlogPage() { + const t = useTranslations('marketing.blog'); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + const heroRef = useRef(null); + const articlesRef = useRef(null); + const categoriesRef = useRef(null); + + const isHeroInView = useInView(heroRef, { once: true }); + 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; + }); + + 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 }, + }, + }; + + return ( +
+ + + {/* Hero Section */} +
+
+
+
+
+ +
+ + + + {t('badge')} + + +

+ {t('title1')} +
+ + {t('title2')} + +

+ +

+ {t('intro')} +

+ + {/* Search Bar */} + +
+ + 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" + /> +
+
+
+
+ + {/* Wave */} +
+ + + +
+
+ + {/* Categories */} +
+ +
+ {CATEGORIES.map((category) => { + const IconComponent = category.icon; + const isActive = selectedCategory === category.key; + return ( + + ); + })} +
+
+
+ + {/* Featured Article */} +
+
+ + +
+
+
+ +
+ +
+
+
+ + {t('featuredBadge')} + + + {t('categories.technology')} + +
+ +

+ {t('featured.title')} +

+ +

{t('featured.excerpt')}

+ +
+
+ + {t('featured.author')} +
+
+ + {t('featured.date')} +
+
+ + {t('featured.readTime')} +
+
+ +
+ {t('readArticle')} + +
+
+
+
+ + +
+
+ + {/* Articles Grid */} +
+
+ +

{t('allTitle')}

+ {t('articlesCount', { count: filteredArticles.length })} +
+ + {filteredArticles.length === 0 ? ( +
+ +

{t('noResults.title')}

+

{t('noResults.body')}

+
+ ) : ( + + {filteredArticles.map((article) => ( + + +
+
+ +
+ + {t(`categories.${article.category}`)} + +
+
+ +
+

+ {t(`articles.${article.key}.title` as any)} +

+ +

+ {t(`articles.${article.key}.excerpt` as any)} +

+ +
+ {article.tags.map((tag) => ( + + {tag} + + ))} +
+ +
+
+
+ +
+ {t(`articles.${article.key}.author` as any)} +
+
+ {t(`articles.${article.key}.date` as any)} + + + {t(`articles.${article.key}.readTime` as any)} + +
+
+
+
+ +
+ ))} +
+ )} + + {/* Load More */} + {filteredArticles.length > 0 && ( + + + + )} +
+
+ + {/* Newsletter Section */} +
+
+ +

+ {t('newsletter.title')} +

+

+ {t('newsletter.body')} +

+
+ + +
+

+ {t('newsletter.disclaimer')} +

+
+
+
+ + +
+ ); +} diff --git a/apps/frontend/app/booking/confirm/[token]/page.tsx b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx similarity index 81% rename from apps/frontend/app/booking/confirm/[token]/page.tsx rename to apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx index e6c5ac9..54be80d 100644 --- a/apps/frontend/app/booking/confirm/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/confirm/[token]/page.tsx @@ -8,21 +8,21 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings'; export default function BookingConfirmPage() { const params = useParams(); - const router = useRouter(); const token = params.token as string; + const t = useTranslations('bookingPortal.confirm'); + const tCommon = useTranslations('bookingPortal.common'); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [booking, setBooking] = useState(null); - const [isAccepting, setIsAccepting] = useState(false); const handleAccept = useCallback(async () => { - setIsAccepting(true); setError(null); try { @@ -33,31 +33,29 @@ export default function BookingConfirmPage() { if (err instanceof Error) { setError(err.message); } else { - setError('Une erreur est survenue lors de l\'acceptation'); + setError(t('errorGeneric')); } } finally { setIsLoading(false); - setIsAccepting(false); } - }, [token]); + }, [token, t]); useEffect(() => { if (!token) { - setError('Token de confirmation invalide'); + setError(t('tokenInvalid')); setIsLoading(false); return; } - // Auto-accept the booking handleAccept(); - }, [token, handleAccept]); + }, [token, handleAccept, t]); if (isLoading) { return (
-

Confirmation en cours...

+

{t('loading')}

); @@ -84,24 +82,24 @@ export default function BookingConfirmPage() {

- Erreur de confirmation + {t('errorTitle')}

{error}

- Raisons possibles : + {t('errorReasonsTitle')}

    -
  • Le lien a expiré
  • -
  • La demande a déjà été acceptée ou refusée
  • -
  • Le token de confirmation est invalide
  • +
  • {t('errorReason1')}
  • +
  • {t('errorReason2')}
  • +
  • {t('errorReason3')}

- Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement. + {t('errorContact')}

@@ -133,67 +131,68 @@ export default function BookingConfirmPage() { /> - {/* Animated rings */}

- Demande acceptée ! + {t('successTitle')}

- Merci d'avoir accepté cette demande de transport. + {t('successHeadline')}

- Le client a été notifié par email. + {t('successBody')}

{/* Booking Summary */}

- Récapitulatif de la réservation + {t('summaryTitle')}

- ID Réservation + {t('labels.bookingId')} {booking.bookingId}
- Trajet + {t('labels.route')} {booking.origin} → {booking.destination}
- Volume + {t('labels.volume')} {booking.volumeCBM} CBM
- Poids + {t('labels.weight')} {booking.weightKG} kg
- Palettes + {t('labels.pallets')} {booking.palletCount}
- Type de conteneur + {t('labels.containerType')} {booking.containerType}
- Temps de transit - {booking.transitDays} jours + {t('labels.transitDays')} + + {t('transitDaysValue', { count: booking.transitDays })} +
- Prix + {t('labels.price')}
{booking.primaryCurrency === 'USD' @@ -213,7 +212,7 @@ export default function BookingConfirmPage() { {booking.notes && (
-

Notes :

+

{t('labels.notes')}

{booking.notes}

)} @@ -225,19 +224,19 @@ export default function BookingConfirmPage() { - Prochaines étapes + {t('nextStepsTitle')}
    -
  • Le client va finaliser les détails du conteneur
  • -
  • Vous recevrez un email avec les documents nécessaires
  • -
  • Le paiement sera traité selon vos conditions habituelles
  • +
  • {t('nextStep1')}
  • +
  • {t('nextStep2')}
  • +
  • {t('nextStep3')}
{/* Documents Section */} {booking.documents && booking.documents.length > 0 && (
-

Documents fournis

+

{t('labels.documents')}

{booking.documents.map((doc, index) => (
@@ -256,7 +255,7 @@ export default function BookingConfirmPage() { rel="noopener noreferrer" className="text-blue-600 hover:text-blue-700 text-sm font-medium" > - Télécharger + {t('labels.download')}
))} @@ -266,7 +265,7 @@ export default function BookingConfirmPage() { {/* Contact Info */}
-

Pour toute question, contactez-nous à

+

{tCommon('supportPrompt')}

support@xpeditis.com diff --git a/apps/frontend/app/booking/reject/[token]/page.tsx b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx similarity index 82% rename from apps/frontend/app/booking/reject/[token]/page.tsx rename to apps/frontend/app/[locale]/booking/reject/[token]/page.tsx index 673195f..f4b8a2a 100644 --- a/apps/frontend/app/booking/reject/[token]/page.tsx +++ b/apps/frontend/app/[locale]/booking/reject/[token]/page.tsx @@ -9,11 +9,14 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings'; export default function BookingRejectPage() { const params = useParams(); const token = params.token as string; + const t = useTranslations('bookingPortal.reject'); + const tCommon = useTranslations('bookingPortal.common'); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -25,14 +28,13 @@ export default function BookingRejectPage() { useEffect(() => { if (!token) { - setError('Token de refus invalide'); + setError(t('tokenInvalid')); setIsLoading(false); return; } - // Just validate the token exists, don't auto-reject setIsLoading(false); - }, [token]); + }, [token, t]); const handleReject = async () => { if (!token) return; @@ -49,7 +51,7 @@ export default function BookingRejectPage() { if (err instanceof Error) { setError(err.message); } else { - setError('Une erreur est survenue lors du refus'); + setError(t('errorGeneric')); } } finally { setIsRejecting(false); @@ -61,7 +63,7 @@ export default function BookingRejectPage() {
-

Chargement...

+

{t('loading')}

); @@ -88,36 +90,34 @@ export default function BookingRejectPage() {

- Erreur de refus + {t('errorTitle')}

{error}

- Raisons possibles : + {t('errorReasonsTitle')}

    -
  • Le lien a expiré
  • -
  • La demande a déjà été acceptée ou refusée
  • -
  • Le token est invalide
  • +
  • {t('errorReason1')}
  • +
  • {t('errorReason2')}
  • +
  • {t('errorReason3')}

- Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement. + {t('errorContact')}

); } - // After successful rejection if (hasRejected && booking) { return (
- {/* Rejection Icon with Animation */}
@@ -138,47 +138,46 @@ export default function BookingRejectPage() {

- Demande refusée + {t('rejectedTitle')}

- Vous avez refusé cette demande de transport. + {t('rejectedHeadline')}

- Le client a été notifié par email. + {t('rejectedBody')}

- {/* Booking Summary */}

- Récapitulatif de la demande refusée + {t('summaryTitle')}

- ID Réservation + {t('labels.bookingId')} {booking.bookingId}
- Trajet + {t('labels.route')} {booking.origin} → {booking.destination}
- Volume + {t('labels.volume')} {booking.volumeCBM} CBM
- Poids + {t('labels.weight')} {booking.weightKG} kg
- Prix proposé + {t('labels.proposedPrice')} {booking.primaryCurrency === 'USD' ? `$${booking.priceUSD.toLocaleString()}` @@ -190,7 +189,7 @@ export default function BookingRejectPage() { {reason && (
-

Raison du refus :

+

{t('labels.rejectionReason')}

{reason}

@@ -198,22 +197,20 @@ export default function BookingRejectPage() { )}
- {/* Info Message */}

- Information + {t('infoTitle')}

- Le client pourra soumettre une nouvelle demande avec des conditions différentes si nécessaire. + {t('infoBody')}

- {/* Contact Info */}
-

Pour toute question, contactez-nous à

+

{tCommon('supportPrompt')}

support@xpeditis.com @@ -243,11 +240,9 @@ export default function BookingRejectPage() { ); } - // Initial rejection form return (
- {/* Warning Icon */}

- Refuser cette demande + {t('formTitle')}

- Vous êtes sur le point de refuser cette demande de transport. + {t('formIntro')}

- {/* Optional Reason Field */}
{!showReasonField ? (