Compare commits

..

6 Commits

Author SHA1 Message Date
David
5a54940424 chore: sync main with preprod (remove smoke tests + latest changes)
Some checks failed
CD Production / Backend — Lint (push) Successful in 10m22s
CD Production / Frontend — Lint & Type-check (push) Successful in 10m53s
CD Production / Backend — Unit Tests (push) Successful in 10m10s
CD Production / Frontend — Unit Tests (push) Successful in 10m30s
CD Production / Verify Preprod Image Exists (push) Failing after 9s
CD Production / Promote Images (preprod-SHA → prod) (push) Has been skipped
CD Production / Deploy to Production (k3s) (push) Has been skipped
CD Production / Notify Success (push) Has been skipped
CD Production / Notify Failure (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:13:51 +02:00
David
ce8a1049dd fix(cicd): sync corrected pipelines from cicd branch
Some checks failed
CD Production / Frontend — Lint & Type-check (push) Failing after 6m11s
CD Production / Frontend — Unit Tests (push) Has been skipped
CD Production / Backend — Lint (push) Successful in 10m24s
CD Production / Backend — Unit Tests (push) Failing after 5m32s
CD Production / Verify Preprod Image Exists (push) Has been skipped
CD Production / Promote Images (preprod-SHA → prod) (push) Has been skipped
CD Production / Deploy to Production (k3s) (push) Has been skipped
CD Production / Smoke Tests (push) Has been skipped
CD Production / Notify Success (push) Has been skipped
CD Production / Notify Failure (push) Has been skipped
2026-04-04 13:16:48 +02:00
David
9c511c0619 revert: restore root-level docs mistakenly deleted
Some checks failed
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Successful in 31s
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Has been cancelled
CD Production (Hetzner k3s) / Smoke Tests (push) Has been cancelled
CD Production (Hetzner k3s) / Deployment Summary (push) Has been cancelled
CD Production (Hetzner k3s) / Notify Success (push) Has been cancelled
CD Production (Hetzner k3s) / Notify Failure (push) Has been cancelled
2026-04-04 13:02:26 +02:00
David
9a79777e34 chore: remove stale root-level docs (already in docs/installation/)
Some checks are pending
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Waiting to run
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:58:28 +02:00
David
d65cb721b5 chore: sync full codebase from cicd branch
Some checks are pending
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Waiting to run
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
Aligns main with the complete application codebase (cicd branch).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:56:44 +02:00
David
b7f85c9bf9 feat(cicd): sync CI/CD pipeline from cicd branch
Some checks failed
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
Security Audit / npm audit (push) Failing after 7s
Security Audit / Dependency Review (push) Has been skipped
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:52:56 +02:00
182 changed files with 17450 additions and 22193 deletions

View File

@ -6,8 +6,6 @@
"deleteOutDir": true, "deleteOutDir": true,
"builder": "tsc", "builder": "tsc",
"tsConfigPath": "tsconfig.build.json", "tsConfigPath": "tsconfig.build.json",
"plugins": ["@nestjs/swagger"], "plugins": ["@nestjs/swagger"]
"assets": [{ "include": "i18n/**/*.json", "outDir": "dist" }],
"watchAssets": true
} }
} }

View File

@ -43,7 +43,6 @@
"joi": "^17.11.0", "joi": "^17.11.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"mjml": "^4.16.1", "mjml": "^4.16.1",
"nestjs-i18n": "^10.6.5",
"nestjs-pino": "^4.4.1", "nestjs-pino": "^4.4.1",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.9",
"opossum": "^8.1.3", "opossum": "^8.1.3",
@ -5762,12 +5761,6 @@
"node": ">=6.5" "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -12176,34 +12169,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT" "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": { "node_modules/nestjs-pino": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz", "resolved": "https://registry.npmjs.org/nestjs-pino/-/nestjs-pino-4.4.1.tgz",
@ -14507,12 +14472,6 @@
"safe-buffer": "~5.2.0" "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": { "node_modules/string-length": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",

View File

@ -59,7 +59,6 @@
"joi": "^17.11.0", "joi": "^17.11.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"mjml": "^4.16.1", "mjml": "^4.16.1",
"nestjs-i18n": "^10.6.5",
"nestjs-pino": "^4.4.1", "nestjs-pino": "^4.4.1",
"nodemailer": "^7.0.9", "nodemailer": "^7.0.9",
"opossum": "^8.1.3", "opossum": "^8.1.3",

View File

@ -3,16 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { APP_GUARD } from '@nestjs/core'; 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 * as Joi from 'joi';
import { UserPreferenceResolver } from './infrastructure/i18n/user-preference.resolver';
// Import feature modules // Import feature modules
import { AuthModule } from './application/auth/auth.module'; import { AuthModule } from './application/auth/auth.module';
@ -119,29 +110,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
inject: [ConfigService], 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 // Database
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({ useFactory: (configService: ConfigService) => ({

View File

@ -10,7 +10,13 @@ import {
Post, Post,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { CurrentUser } from '../decorators/current-user.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator'; import { RequiresFeature } from '../decorators/requires-feature.decorator';
@ -32,7 +38,7 @@ export class ApiKeysController {
@ApiOperation({ @ApiOperation({
summary: 'Générer une nouvelle clé API', summary: 'Générer une nouvelle clé API',
description: 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({ @ApiResponse({
status: 201, status: 201,

View File

@ -23,7 +23,10 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
import { FeatureFlagGuard } from '../guards/feature-flag.guard'; import { FeatureFlagGuard } from '../guards/feature-flag.guard';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]), SubscriptionsModule], imports: [
TypeOrmModule.forFeature([ApiKeyOrmEntity, UserOrmEntity]),
SubscriptionsModule,
],
controllers: [ApiKeysController], controllers: [ApiKeysController],
providers: [ providers: [
ApiKeysService, ApiKeysService,

View File

@ -8,7 +8,13 @@
* - Validation for inbound API key authentication * - 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 * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';

View File

@ -41,12 +41,7 @@ import { SubscriptionsModule } from '../subscriptions/subscriptions.module';
}), }),
// 👇 Add this to register TypeORM repositories // 👇 Add this to register TypeORM repositories
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, InvitationTokenOrmEntity, PasswordResetTokenOrmEntity]),
UserOrmEntity,
OrganizationOrmEntity,
InvitationTokenOrmEntity,
PasswordResetTokenOrmEntity,
]),
// Email module for sending invitations // Email module for sending invitations
EmailModule, EmailModule,

View File

@ -265,9 +265,7 @@ export class AuthService {
} }
if (resetToken.expiresAt < new Date()) { if (resetToken.expiresAt < new Date()) {
throw new BadRequestException( throw new BadRequestException('Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.');
'Le lien de réinitialisation a expiré. Veuillez en demander un nouveau.'
);
} }
const user = await this.userRepository.findById(resetToken.userId); const user = await this.userRepository.findById(resetToken.userId);
@ -288,7 +286,10 @@ export class AuthService {
await this.userRepository.save(user); await this.userRepository.save(user);
// Mark token as used // 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}`); this.logger.log(`Password reset successfully for user: ${user.email}`);
} }

View File

@ -744,7 +744,10 @@ export class AdminController {
}) })
@ApiResponse({ status: 200, description: 'Email sent successfully' }) @ApiResponse({ status: 200, description: 'Email sent successfully' })
@ApiResponse({ status: 400, description: 'SMTP error — check the message field' }) @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) { if (!body?.to) {
throw new BadRequestException('Field "to" is required'); throw new BadRequestException('Field "to" is required');
} }
@ -877,9 +880,7 @@ export class AdminController {
@Param('documentId', ParseUUIDPipe) documentId: string, @Param('documentId', ParseUUIDPipe) documentId: string,
@CurrentUser() user: UserPayload @CurrentUser() user: UserPayload
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
this.logger.log( this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`
);
const booking = await this.csvBookingRepository.findById(bookingId); const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) { if (!booking) {
@ -893,9 +894,7 @@ export class AdminController {
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId); const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
const ormBooking = await this.csvBookingRepository['repository'].findOne({ const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
where: { id: bookingId },
});
if (ormBooking) { if (ormBooking) {
ormBooking.documents = updatedDocuments.map(doc => ({ ormBooking.documents = updatedDocuments.map(doc => ({
id: doc.id, id: doc.id,

View File

@ -289,9 +289,7 @@ export class AuthController {
}); });
} catch (error) { } catch (error) {
this.logger.error(`Failed to send contact email: ${error}`); this.logger.error(`Failed to send contact email: ${error}`);
throw new InternalServerErrorException( throw new InternalServerErrorException("Erreur lors de l'envoi du message. Veuillez réessayer.");
"Erreur lors de l'envoi du message. Veuillez réessayer."
);
} }
return { message: 'Message envoyé avec succès.' }; return { message: 'Message envoyé avec succès.' };

View File

@ -153,7 +153,10 @@ export class InvitationsController {
@ApiResponse({ status: 204, description: 'Invitation cancelled' }) @ApiResponse({ status: 204, description: 'Invitation cancelled' })
@ApiResponse({ status: 404, description: 'Invitation not found' }) @ApiResponse({ status: 404, description: 'Invitation not found' })
@ApiResponse({ status: 400, description: 'Invitation already used' }) @ApiResponse({ status: 400, description: 'Invitation already used' })
async cancelInvitation(@Param('id') id: string, @CurrentUser() user: UserPayload): Promise<void> { async cancelInvitation(
@Param('id') id: string,
@CurrentUser() user: UserPayload
): Promise<void> {
this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`); this.logger.log(`[User: ${user.email}] Cancelling invitation: ${id}`);
await this.invitationService.cancelInvitation(id, user.organizationId); await this.invitationService.cancelInvitation(id, user.organizationId);
} }

View File

@ -166,16 +166,27 @@ export class RatesController {
); );
try { try {
// Map DTO to domain input
const searchInput = { const searchInput = {
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
volumeCBM: dto.volumeCBM, volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG, weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType, containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
}; };
// Execute CSV rate search
const result = await this.csvRateSearchService.execute(searchInput); const result = await this.csvRateSearchService.execute(searchInput);
// Map domain output to response DTO // Map domain output to response DTO
@ -230,16 +241,27 @@ export class RatesController {
); );
try { try {
// Map DTO to domain input
const searchInput = { const searchInput = {
origin: dto.origin, origin: dto.origin,
destination: dto.destination, destination: dto.destination,
volumeCBM: dto.volumeCBM, volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG, weightKG: dto.weightKG,
palletCount: dto.palletCount ?? 0,
containerType: dto.containerType, containerType: dto.containerType,
hasDangerousGoods: dto.hasDangerousGoods ?? false,
filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters), filters: this.csvRateMapper.mapFiltersDtoToDomain(dto.filters),
// Service requirements for detailed pricing
hasDangerousGoods: dto.hasDangerousGoods ?? false,
requiresSpecialHandling: dto.requiresSpecialHandling ?? false,
requiresTailgate: dto.requiresTailgate ?? false,
requiresStraps: dto.requiresStraps ?? false,
requiresThermalCover: dto.requiresThermalCover ?? false,
hasRegulatedProducts: dto.hasRegulatedProducts ?? false,
requiresAppointment: dto.requiresAppointment ?? false,
}; };
// Execute CSV rate search WITH OFFERS GENERATION
const result = await this.csvRateSearchService.executeWithOffers(searchInput); const result = await this.csvRateSearchService.executeWithOffers(searchInput);
// Map domain output to response DTO // Map domain output to response DTO

View File

@ -1,5 +1,4 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Locale } from '@domain/value-objects/locale.vo';
/** /**
* User payload interface extracted from JWT * User payload interface extracted from JWT
@ -11,7 +10,6 @@ export interface UserPayload {
organizationId: string; organizationId: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
preferredLanguage?: Locale;
} }
/** /**

View File

@ -11,192 +11,384 @@ import {
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { RateSearchFiltersDto } from './rate-search-filters.dto'; import { RateSearchFiltersDto } from './rate-search-filters.dto';
/**
* CSV Rate Search Request DTO
*
* Request body for searching rates in CSV-based system
* Includes basic search parameters + optional advanced filters
*/
export class CsvRateSearchDto { export class CsvRateSearchDto {
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' }) @ApiProperty({
description: 'Origin port code (UN/LOCODE format)',
example: 'NLRTM',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
origin: string; origin: string;
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' }) @ApiProperty({
description: 'Destination port code (UN/LOCODE format)',
example: 'USNYC',
pattern: '^[A-Z]{2}[A-Z0-9]{3}$',
})
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
destination: string; destination: string;
@ApiProperty({ description: 'Volume in cubic meters (CBM)', minimum: 0.01, example: 10.5 }) @ApiProperty({
description: 'Volume in cubic meters (CBM)',
minimum: 0.01,
example: 25.5,
})
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(0.01) @Min(0.01)
volumeCBM: number; volumeCBM: number;
@ApiProperty({ description: 'Weight in kilograms', minimum: 1, example: 2500 }) @ApiProperty({
description: 'Weight in kilograms',
minimum: 1,
example: 3500,
})
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
weightKG: number; weightKG: number;
@ApiPropertyOptional({ description: 'Container type filter', example: 'LCL' }) @ApiPropertyOptional({
description: 'Number of pallets (0 if no pallets)',
minimum: 0,
example: 10,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Container type filter (e.g., LCL, 20DRY, 40HC)',
example: 'LCL',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
containerType?: string; containerType?: string;
@ApiPropertyOptional({ description: 'Cargo contains dangerous goods', example: false }) @ApiPropertyOptional({
@IsOptional() description: 'Advanced filters for narrowing results',
@IsBoolean() type: RateSearchFiltersDto,
hasDangerousGoods?: boolean; })
@ApiPropertyOptional({ description: 'Advanced filters', type: RateSearchFiltersDto })
@IsOptional() @IsOptional()
@ValidateNested() @ValidateNested()
@Type(() => RateSearchFiltersDto) @Type(() => RateSearchFiltersDto)
filters?: RateSearchFiltersDto; filters?: RateSearchFiltersDto;
// Service requirements for detailed price calculation
@ApiPropertyOptional({
description: 'Cargo contains dangerous goods (DG)',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
hasDangerousGoods?: boolean;
@ApiPropertyOptional({
description: 'Requires special handling',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresSpecialHandling?: boolean;
@ApiPropertyOptional({
description: 'Requires tailgate lift',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresTailgate?: boolean;
@ApiPropertyOptional({
description: 'Requires securing straps',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresStraps?: boolean;
@ApiPropertyOptional({
description: 'Requires thermal protection cover',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
requiresThermalCover?: boolean;
@ApiPropertyOptional({
description: 'Contains regulated products requiring special documentation',
example: false,
default: false,
})
@IsOptional()
@IsBoolean()
hasRegulatedProducts?: boolean;
@ApiPropertyOptional({
description: 'Requires delivery appointment',
example: true,
default: false,
})
@IsOptional()
@IsBoolean()
requiresAppointment?: boolean;
} }
/**
* CSV Rate Search Response DTO
*
* Response containing matching rates with calculated prices
*/
export class CsvRateSearchResponseDto { export class CsvRateSearchResponseDto {
@ApiProperty({ description: 'Array of matching rate results', type: [Object] }) @ApiProperty({
description: 'Array of matching rate results',
type: [Object], // Will be replaced with RateResultDto
})
results: CsvRateResultDto[]; results: CsvRateResultDto[];
@ApiProperty({ description: 'Total number of results', example: 12 }) @ApiProperty({
description: 'Total number of results found',
example: 15,
})
totalResults: number; totalResults: number;
@ApiProperty({ description: 'CSV files searched', type: [String] }) @ApiProperty({
description: 'CSV files that were searched',
type: [String],
example: ['ssc-consolidation.csv', 'ecu-worldwide.csv'],
})
searchedFiles: string[]; searchedFiles: string[];
@ApiProperty({ description: 'Timestamp of search', example: '2026-05-11T10:30:00Z' }) @ApiProperty({
description: 'Timestamp when search was executed',
example: '2025-10-23T10:30:00Z',
})
searchedAt: Date; searchedAt: Date;
@ApiProperty({ description: 'Applied filters' }) @ApiProperty({
description: 'Filters that were applied to the search',
type: RateSearchFiltersDto,
})
appliedFilters: RateSearchFiltersDto; appliedFilters: RateSearchFiltersDto;
} }
export class FobBreakdownDto { /**
documentation: number; * Surcharge Item DTO
isps: number; */
handling: number; export class SurchargeItemDto {
solas: number; @ApiProperty({
customs: number; description: 'Surcharge code',
ams_aci: number; example: 'DG_FEE',
isf5: number;
dgAdmin: number;
}
export class PriceBreakdownDto {
@ApiProperty({ description: 'Freight charge', example: 420.0 })
freightCharge: number;
@ApiProperty({ description: 'Freight currency', example: 'USD' })
freightCurrency: string;
@ApiProperty({ description: 'Fixed FOB charges (doc+ISPS+solas+customs+AMS+ISF5)', example: 185 })
fobFixed: number;
@ApiProperty({ description: 'FOB handling charge', example: 60 })
fobHandling: number;
@ApiProperty({ description: 'DG admin fee (FOB currency, 0 if non-DG)', example: 0 })
fobDG: number;
@ApiProperty({ description: 'FOB currency', example: 'EUR' })
fobCurrency: string;
@ApiProperty({ description: 'Itemized FOB breakdown', type: FobBreakdownDto })
fobBreakdown: FobBreakdownDto;
@ApiPropertyOptional({
description: 'DG surcharge amount (null if on_request/not_accepted)',
example: null,
}) })
dgSurchargeAmount: number | null; code: string;
@ApiProperty({ description: 'DG surcharge currency', example: 'EUR' })
dgSurchargeCurrency: string;
@ApiProperty({ @ApiProperty({
description: 'DG surcharge status', description: 'Surcharge description',
enum: ['computed', 'on_request', 'not_accepted'], example: 'Dangerous goods fee',
example: 'computed',
}) })
dgSurchargeStatus: string; description: string;
@ApiProperty({ description: 'Total freight in freightCurrency', example: 420.0 }) @ApiProperty({
totalFreight: number; description: 'Surcharge amount in currency',
example: 65.0,
})
amount: number;
@ApiProperty({ description: 'Total FOB in fobCurrency', example: 245 }) @ApiProperty({
totalFob: number; description: 'Type of surcharge calculation',
enum: ['FIXED', 'PER_UNIT', 'PERCENTAGE'],
@ApiProperty({ description: 'Sum for sorting (currency-naive)', example: 665.0 }) example: 'FIXED',
totalPriceForSorting: number; })
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
@ApiProperty({ description: 'Primary currency', example: 'USD' })
primaryCurrency: string;
} }
/**
* Price Breakdown DTO
*/
export class PriceBreakdownDto {
@ApiProperty({
description: 'Base price before any charges',
example: 0,
})
basePrice: number;
@ApiProperty({
description: 'Charge based on volume (CBM)',
example: 150.0,
})
volumeCharge: number;
@ApiProperty({
description: 'Charge based on weight (KG)',
example: 25.0,
})
weightCharge: number;
@ApiProperty({
description: 'Charge for pallets',
example: 125.0,
})
palletCharge: number;
@ApiProperty({
description: 'List of all surcharges',
type: [SurchargeItemDto],
})
surcharges: SurchargeItemDto[];
@ApiProperty({
description: 'Total of all surcharges',
example: 242.0,
})
totalSurcharges: number;
@ApiProperty({
description: 'Total price including all charges',
example: 542.0,
})
totalPrice: number;
@ApiProperty({
description: 'Currency of the pricing',
enum: ['USD', 'EUR'],
example: 'USD',
})
currency: string;
}
/**
* Single CSV Rate Result DTO
*/
export class CsvRateResultDto { export class CsvRateResultDto {
@ApiProperty({ example: 'SSC Consolidation' }) @ApiProperty({
description: 'Company name',
example: 'SSC Consolidation',
})
companyName: string; companyName: string;
@ApiProperty({ example: 'bookings@ssc.com' }) @ApiProperty({
description: 'Company email for booking requests',
example: 'bookings@sscconsolidation.com',
})
companyEmail: string; companyEmail: string;
@ApiProperty({ description: 'Origin CFS name', example: 'Fos Sur Mer' }) @ApiProperty({
originCFS: string; description: 'Origin port code',
example: 'NLRTM',
@ApiProperty({ description: 'Origin UN/LOCODE', example: 'FRFOS' }) })
origin: string; origin: string;
@ApiProperty({ description: 'Port of loading', example: 'FOS SUR MER' }) @ApiProperty({
portOfLoading: string; description: 'Destination port code',
example: 'USNYC',
@ApiProperty({ description: 'Routing type', example: 'Direct' }) })
routing: string;
@ApiProperty({ description: 'Destination CFS name', example: 'Shanghai' })
destinationCFS: string;
@ApiProperty({ description: 'Destination UN/LOCODE', example: 'CNSHA' })
destination: string; destination: string;
@ApiProperty({ description: 'Destination country', example: 'China' }) @ApiProperty({
destinationCountry: string; description: 'Container type',
example: 'LCL',
@ApiProperty({ example: 'LCL' }) })
containerType: string; containerType: string;
@ApiProperty({ description: 'Detailed price breakdown', type: PriceBreakdownDto }) @ApiProperty({
description: 'Calculated price in USD',
example: 1850.5,
})
priceUSD: number;
@ApiProperty({
description: 'Calculated price in EUR',
example: 1665.45,
})
priceEUR: number;
@ApiProperty({
description: 'Primary currency of the rate',
enum: ['USD', 'EUR'],
example: 'USD',
})
primaryCurrency: string;
@ApiProperty({
description: 'Detailed price breakdown with all charges',
type: PriceBreakdownDto,
})
priceBreakdown: PriceBreakdownDto; priceBreakdown: PriceBreakdownDto;
@ApiProperty({ description: 'Departure frequency', example: 'Weekly' }) @ApiProperty({
frequency: string; description: 'Whether this rate has separate surcharges',
example: true,
})
hasSurcharges: boolean;
@ApiProperty({ description: 'Transit time (adjusted if service level)', example: 28 }) @ApiProperty({
description: 'Details of surcharges if any',
example: 'BAF+CAF included',
nullable: true,
})
surchargeDetails: string | null;
@ApiProperty({
description: 'Transit time in days',
example: 28,
})
transitDays: number; transitDays: number;
@ApiProperty({ description: 'Rate validity end date', example: '2026-12-31' }) @ApiProperty({
description: 'Rate validity end date',
example: '2025-12-31',
})
validUntil: string; validUntil: string;
@ApiProperty({ description: 'Whether DG cargo is accepted', example: true }) @ApiProperty({
dgAccepted: boolean; description: 'Source of the rate',
enum: ['CSV', 'API'],
example: 'CSV',
})
source: 'CSV' | 'API';
@ApiProperty({ description: 'DG surcharge status', example: 'computed' }) @ApiProperty({
dgSurchargeStatus: string; description: 'Match score (0-100) indicating how well this rate matches the search',
minimum: 0,
@ApiProperty({ description: 'Internal remarks', example: 'GR1/GR2' }) maximum: 100,
remarks: string; example: 95,
})
@ApiProperty({ example: 'CSV' })
source: 'CSV';
@ApiProperty({ description: 'Match score 0-100', example: 95 })
matchScore: number; matchScore: number;
@ApiPropertyOptional({ enum: ['RAPID', 'STANDARD', 'ECONOMIC'] }) @ApiPropertyOptional({
description: 'Service level (only present when using search-csv-offers endpoint)',
enum: ['RAPID', 'STANDARD', 'ECONOMIC'],
example: 'RAPID',
})
serviceLevel?: string; serviceLevel?: string;
@ApiPropertyOptional({ description: 'Price multiplier for service level', example: 1.0 }) @ApiPropertyOptional({
priceMultiplier?: number; description: 'Original price before service level adjustment',
example: { usd: 1500.0, eur: 1350.0 },
})
originalPrice?: {
usd: number;
eur: number;
};
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Original transit days before service level adjustment', description: 'Original transit days before service level adjustment',
example: 28, example: 20,
}) })
originalTransitDays?: number; originalTransitDays?: number;
} }

View File

@ -10,9 +10,15 @@ import {
IsString, IsString,
} from 'class-validator'; } from 'class-validator';
/**
* Rate Search Filters DTO
*
* Advanced filters for narrowing down rate search results
* All filters are optional
*/
export class RateSearchFiltersDto { export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'List of company names to include', description: 'List of company names to include in search',
type: [String], type: [String],
example: ['SSC Consolidation', 'ECU Worldwide'], example: ['SSC Consolidation', 'ECU Worldwide'],
}) })
@ -22,25 +28,59 @@ export class RateSearchFiltersDto {
companies?: string[]; companies?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Only show "Direct" routing (exclude transhipment)', description: 'Minimum volume in CBM (cubic meters)',
example: false,
})
@IsOptional()
@IsBoolean()
onlyDirect?: boolean;
@ApiPropertyOptional({
description: 'Exclude routes where DG is not accepted',
example: false,
})
@IsOptional()
@IsBoolean()
excludeNonDgRoutes?: boolean;
@ApiPropertyOptional({
description: 'Minimum price (totalPriceForSorting)',
minimum: 0, minimum: 0,
example: 500, example: 1,
})
@IsOptional()
@IsNumber()
@Min(0)
minVolumeCBM?: number;
@ApiPropertyOptional({
description: 'Maximum volume in CBM (cubic meters)',
minimum: 0,
example: 100,
})
@IsOptional()
@IsNumber()
@Min(0)
maxVolumeCBM?: number;
@ApiPropertyOptional({
description: 'Minimum weight in kilograms',
minimum: 0,
example: 100,
})
@IsOptional()
@IsNumber()
@Min(0)
minWeightKG?: number;
@ApiPropertyOptional({
description: 'Maximum weight in kilograms',
minimum: 0,
example: 15000,
})
@IsOptional()
@IsNumber()
@Min(0)
maxWeightKG?: number;
@ApiPropertyOptional({
description: 'Exact number of pallets (0 means any)',
minimum: 0,
example: 10,
})
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({
description: 'Minimum price in selected currency',
minimum: 0,
example: 1000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -48,9 +88,9 @@ export class RateSearchFiltersDto {
minPrice?: number; minPrice?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum price (totalPriceForSorting)', description: 'Maximum price in selected currency',
minimum: 0, minimum: 0,
example: 3000, example: 5000,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -70,7 +110,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Maximum transit time in days', description: 'Maximum transit time in days',
minimum: 0, minimum: 0,
example: 45, example: 40,
}) })
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@ -80,7 +120,7 @@ export class RateSearchFiltersDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Container types to filter by', description: 'Container types to filter by',
type: [String], type: [String],
example: ['LCL'], example: ['LCL', '20DRY', '40HC'],
}) })
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ -88,7 +128,7 @@ export class RateSearchFiltersDto {
containerTypes?: string[]; containerTypes?: string[];
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Preferred currency for price display', description: 'Preferred currency for price filtering',
enum: ['USD', 'EUR'], enum: ['USD', 'EUR'],
example: 'USD', example: 'USD',
}) })
@ -96,9 +136,17 @@ export class RateSearchFiltersDto {
@IsEnum(['USD', 'EUR']) @IsEnum(['USD', 'EUR'])
currency?: 'USD' | 'EUR'; currency?: 'USD' | 'EUR';
@ApiPropertyOptional({
description: 'Only show all-in prices (without separate surcharges)',
example: false,
})
@IsOptional()
@IsBoolean()
onlyAllInPrices?: boolean;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: 'Departure date to check rate validity (ISO 8601)', description: 'Departure date to check rate validity (ISO 8601)',
example: '2026-06-15', example: '2025-06-15',
}) })
@IsOptional() @IsOptional()
@IsDateString() @IsDateString()

View File

@ -1,44 +0,0 @@
/**
* 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<Record<string, unknown>>) {}
catch(exception: DomainException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
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,
});
}
}

View File

@ -1,4 +1,12 @@
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 { Response } from 'express';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { JwtAuthGuard } from '../guards/jwt-auth.guard';
@ -14,7 +22,7 @@ export class LogsController {
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.logExporterUrl = this.configService.get<string>( this.logExporterUrl = this.configService.get<string>(
'LOG_EXPORTER_URL', 'LOG_EXPORTER_URL',
'http://xpeditis-log-exporter:3200' 'http://xpeditis-log-exporter:3200',
); );
} }
@ -31,7 +39,10 @@ export class LogsController {
if (!res.ok) throw new Error(`log-exporter error: ${res.status}`); if (!res.ok) throw new Error(`log-exporter error: ${res.status}`);
return res.json(); return res.json();
} catch (err: any) { } catch (err: any) {
throw new HttpException({ error: err.message }, HttpStatus.BAD_GATEWAY); throw new HttpException(
{ error: err.message },
HttpStatus.BAD_GATEWAY,
);
} }
} }
@ -48,7 +59,7 @@ export class LogsController {
@Query('end') end: string, @Query('end') end: string,
@Query('limit') limit: string, @Query('limit') limit: string,
@Query('format') format: string = 'json', @Query('format') format: string = 'json',
@Res() res: Response @Res() res: Response,
) { ) {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -60,9 +71,10 @@ export class LogsController {
if (limit) params.set('limit', limit); if (limit) params.set('limit', limit);
params.set('format', format); params.set('format', format);
const upstream = await fetch(`${this.logExporterUrl}/api/logs/export?${params}`, { const upstream = await fetch(
signal: AbortSignal.timeout(30000), `${this.logExporterUrl}/api/logs/export?${params}`,
}); { signal: AbortSignal.timeout(30000) },
);
if (!upstream.ok) { if (!upstream.ok) {
const body = await upstream.json().catch(() => ({})); const body = await upstream.json().catch(() => ({}));

View File

@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { CsvRateResultDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
CsvRateResultDto,
CsvRateSearchResponseDto,
PriceBreakdownDto,
FobBreakdownDto,
} from '../dto/csv-rate-search.dto';
import { import {
CsvRateSearchOutput, CsvRateSearchOutput,
CsvRateSearchResult, CsvRateSearchResult,
@ -14,92 +9,100 @@ import { RateSearchFiltersDto } from '../dto/rate-search-filters.dto';
import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto'; import { CsvRateConfigDto } from '../dto/csv-rate-upload.dto';
import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity'; import { CsvRateConfigOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-rate-config.orm-entity';
/**
* CSV Rate Mapper
*
* Maps between domain entities and DTOs
* Follows hexagonal architecture principles
*/
@Injectable() @Injectable()
export class CsvRateMapper { export class CsvRateMapper {
/**
* Map DTO filters to domain filters
*/
mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined { mapFiltersDtoToDomain(dto?: RateSearchFiltersDto): RateSearchFilters | undefined {
if (!dto) return undefined; if (!dto) {
return undefined;
}
return { return {
companies: dto.companies, companies: dto.companies,
onlyDirect: dto.onlyDirect, minVolumeCBM: dto.minVolumeCBM,
excludeNonDgRoutes: dto.excludeNonDgRoutes, maxVolumeCBM: dto.maxVolumeCBM,
minWeightKG: dto.minWeightKG,
maxWeightKG: dto.maxWeightKG,
palletCount: dto.palletCount,
minPrice: dto.minPrice, minPrice: dto.minPrice,
maxPrice: dto.maxPrice, maxPrice: dto.maxPrice,
currency: dto.currency, currency: dto.currency,
minTransitDays: dto.minTransitDays, minTransitDays: dto.minTransitDays,
maxTransitDays: dto.maxTransitDays, maxTransitDays: dto.maxTransitDays,
containerTypes: dto.containerTypes, containerTypes: dto.containerTypes,
onlyAllInPrices: dto.onlyAllInPrices,
departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined, departureDate: dto.departureDate ? new Date(dto.departureDate) : undefined,
}; };
} }
/**
* Map domain search result to DTO
*/
mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto { mapSearchResultToDto(result: CsvRateSearchResult): CsvRateResultDto {
const rate = result.rate; const rate = result.rate;
const bd = result.priceBreakdown;
const fobBreakdown: FobBreakdownDto = {
documentation: bd.fobBreakdown.documentation,
isps: bd.fobBreakdown.isps,
handling: bd.fobBreakdown.handling,
solas: bd.fobBreakdown.solas,
customs: bd.fobBreakdown.customs,
ams_aci: bd.fobBreakdown.ams_aci,
isf5: bd.fobBreakdown.isf5,
dgAdmin: bd.fobBreakdown.dgAdmin,
};
const priceBreakdown: PriceBreakdownDto = {
freightCharge: bd.freightCharge,
freightCurrency: bd.freightCurrency,
fobFixed: bd.fobFixed,
fobHandling: bd.fobHandling,
fobDG: bd.fobDG,
fobCurrency: bd.fobCurrency,
fobBreakdown,
dgSurchargeAmount: bd.dgSurchargeAmount,
dgSurchargeCurrency: bd.dgSurchargeCurrency,
dgSurchargeStatus: bd.dgSurchargeStatus,
totalFreight: bd.totalFreight,
totalFob: bd.totalFob,
totalPriceForSorting: bd.totalPriceForSorting,
primaryCurrency: bd.primaryCurrency,
};
return { return {
companyName: rate.companyName, companyName: rate.companyName,
companyEmail: rate.companyEmail, companyEmail: rate.companyEmail,
originCFS: rate.originCFS, origin: rate.origin.getValue(),
origin: rate.originCode.getValue(), destination: rate.destination.getValue(),
portOfLoading: rate.portOfLoading,
routing: rate.routing,
destinationCFS: rate.destinationCFS,
destination: rate.destinationCode.getValue(),
destinationCountry: rate.destinationCountry,
containerType: rate.containerType.getValue(), containerType: rate.containerType.getValue(),
priceBreakdown, priceUSD: result.calculatedPrice.usd,
frequency: rate.frequency, priceEUR: result.calculatedPrice.eur,
primaryCurrency: result.calculatedPrice.primaryCurrency,
priceBreakdown: {
basePrice: result.priceBreakdown.basePrice,
volumeCharge: result.priceBreakdown.volumeCharge,
weightCharge: result.priceBreakdown.weightCharge,
palletCharge: result.priceBreakdown.palletCharge,
surcharges: result.priceBreakdown.surcharges.map(s => ({
code: s.code,
description: s.description,
amount: s.amount,
type: s.type,
})),
totalSurcharges: result.priceBreakdown.totalSurcharges,
totalPrice: result.priceBreakdown.totalPrice,
currency: result.priceBreakdown.currency,
},
hasSurcharges: rate.hasSurcharges(),
surchargeDetails: rate.hasSurcharges() ? rate.getSurchargeDetails() : null,
// Use adjusted transit days if available (service level offers), otherwise use original
transitDays: result.adjustedTransitDays ?? rate.transitDays, transitDays: result.adjustedTransitDays ?? rate.transitDays,
validUntil: rate.validity.getEndDate().toISOString().split('T')[0], validUntil: rate.validity.getEndDate().toISOString().split('T')[0],
dgAccepted: rate.isDgAccepted(),
dgSurchargeStatus: bd.dgSurchargeStatus,
remarks: rate.remarks,
source: result.source, source: result.source,
matchScore: result.matchScore, matchScore: result.matchScore,
// Include service level fields if present
serviceLevel: result.serviceLevel, serviceLevel: result.serviceLevel,
priceMultiplier: result.priceMultiplier, originalPrice: result.originalPrice,
originalTransitDays: result.originalTransitDays, originalTransitDays: result.originalTransitDays,
}; };
} }
/**
* Map domain search output to response DTO
*/
mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto { mapSearchOutputToResponseDto(output: CsvRateSearchOutput): CsvRateSearchResponseDto {
return { return {
results: output.results.map(r => this.mapSearchResultToDto(r)), results: output.results.map(result => this.mapSearchResultToDto(result)),
totalResults: output.totalResults, totalResults: output.totalResults,
searchedFiles: output.searchedFiles, searchedFiles: output.searchedFiles,
searchedAt: output.searchedAt, searchedAt: output.searchedAt,
appliedFilters: output.appliedFilters as any, appliedFilters: output.appliedFilters as any, // Already matches DTO structure
}; };
} }
/**
* Map ORM entity to DTO
*/
mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto { mapConfigEntityToDto(entity: CsvRateConfigOrmEntity): CsvRateConfigDto {
return { return {
id: entity.id, id: entity.id,
@ -115,7 +118,10 @@ export class CsvRateMapper {
}; };
} }
/**
* Map multiple config entities to DTOs
*/
mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] { mapConfigEntitiesToDtos(entities: CsvRateConfigOrmEntity[]): CsvRateConfigDto[] {
return entities.map(e => this.mapConfigEntityToDto(e)); return entities.map(entity => this.mapConfigEntityToDto(entity));
} }
} }

View File

@ -374,20 +374,18 @@ export class CsvBookingService {
booking.markBankTransferDeclared(); booking.markBankTransferDeclared();
const updatedBooking = await this.csvBookingRepository.update(booking); const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log( this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`
);
// Send email to all ADMIN users // Send email to all ADMIN users
try { try {
const allUsers = await this.userRepository.findAll(); 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) { if (adminEmails.length > 0) {
const commissionAmount = booking.commissionAmountEur const commissionAmount = booking.commissionAmountEur
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format( ? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
booking.commissionAmountEur
)
: 'N/A'; : 'N/A';
await this.emailAdapter.send({ await this.emailAdapter.send({
@ -490,9 +488,7 @@ export class CsvBookingService {
notes: booking.notes, notes: booking.notes,
}); });
this.logger.log( this.logger.log(`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`);
`[ADMIN] Carrier email resent to ${booking.carrierEmail} for booking ${bookingId}`
);
} }
/** /**
@ -548,9 +544,7 @@ export class CsvBookingService {
confirmationToken: booking.confirmationToken, confirmationToken: booking.confirmationToken,
notes: booking.notes, notes: booking.notes,
}); });
this.logger.log( this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`
);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack); this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
} }

View File

@ -70,10 +70,7 @@ export class InvitationService {
} }
// Check if licenses are available for this organization // Check if licenses are available for this organization
const canInviteResult = await this.subscriptionService.canInviteUser( const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
organizationId,
inviterRole
);
if (!canInviteResult.canInvite) { if (!canInviteResult.canInvite) {
this.logger.warn( this.logger.warn(
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}` `License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`

View File

@ -1,69 +1,60 @@
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Money } from '../value-objects/money.vo';
import { Volume } from '../value-objects/volume.vo';
import { SurchargeCollection } from '../value-objects/surcharge.vo';
import { DateRange } from '../value-objects/date-range.vo'; import { DateRange } from '../value-objects/date-range.vo';
export type DgSurchargeValue = number | 'ON REQUEST' | 'NOT ACCEPTED'; /**
export type HandlingUnit = 'W' | 'UP'; // W = tonne revenue (max CBM/T), UP = per CBM * Volume Range - Valid range for CBM
export type FrequencyType = 'Weekly' | 'Bi-Weekly' | 'Bi-Monthly' | 'Monthly'; */
export interface VolumeRange {
export interface FreightPricing { minCBM: number;
freightCurrency: string; maxCBM: number;
freightRatePerCBM: number; // 0.0 = included/to negotiate
freightMinimum: number;
}
export interface FobCharges {
fobCurrency: string;
fobDocumentation: number;
fobISPS: number;
fobHandling: number;
fobHandlingUnit: HandlingUnit;
fobHandlingMinimum: number;
fobSolas: number;
fobCustoms: number;
fobAMS_ACI: number;
fobISF5: number;
fobDGAdmin: number; // Only if DG shipment
}
export interface DgSurchargeInfo {
dgSurchargeCurrency: string;
dgSurchargeRate: DgSurchargeValue;
dgSurchargeUnit: 'UP' | 'LS' | '%'; // per CBM, lump sum, or percentage
dgSurchargeMin: DgSurchargeValue;
} }
/** /**
* CsvRate Shipping rate from a consolidator CSV file. * Weight Range - Valid range for KG
*/
export interface WeightRange {
minKG: number;
maxKG: number;
}
/**
* Rate Pricing - Pricing structure for CSV rates
*/
export interface RatePricing {
pricePerCBM: number;
pricePerKG: number;
basePriceUSD: Money;
basePriceEUR: Money;
}
/**
* CSV Rate Entity
*
* Represents a shipping rate loaded from CSV file.
* Contains all information needed to calculate freight costs.
* *
* Business Rules: * Business Rules:
* - Route matching uses originCode + destinationCode (UN/LOCODE) * - Price is calculated as: max(volumeCBM * pricePerCBM, weightKG * pricePerKG) + surcharges
* - Price = max(freightRatePerCBM×V, freightMinimum) + FOB fixed + handling * - Rate must be valid (within validity period) to be used
* - FOB and freight may be in different currencies * - Volume and weight must be within specified ranges
* - DG surcharge applies only when hasDangerousGoods = true
*/ */
export class CsvRate { export class CsvRate {
constructor( constructor(
// Supplier identity
public readonly companyName: string, public readonly companyName: string,
public readonly companyEmail: string, public readonly companyEmail: string,
// Route geography public readonly origin: PortCode,
public readonly originCFS: string, public readonly destination: PortCode,
public readonly originCode: PortCode,
public readonly portOfLoading: string,
public readonly routing: string,
public readonly destinationCFS: string,
public readonly destinationCode: PortCode,
public readonly destinationCountry: string,
// Container
public readonly containerType: ContainerType, public readonly containerType: ContainerType,
// Pricing public readonly volumeRange: VolumeRange,
public readonly freight: FreightPricing, public readonly weightRange: WeightRange,
public readonly fob: FobCharges, public readonly palletCount: number,
public readonly dgSurcharge: DgSurchargeInfo, public readonly pricing: RatePricing,
// Metadata public readonly currency: string, // Primary currency (USD or EUR)
public readonly remarks: string, public readonly surcharges: SurchargeCollection,
public readonly frequency: FrequencyType,
public readonly transitDays: number, public readonly transitDays: number,
public readonly validity: DateRange public readonly validity: DateRange
) { ) {
@ -71,56 +62,178 @@ export class CsvRate {
} }
private validate(): void { private validate(): void {
if (!this.companyName?.trim()) throw new Error('Company name is required'); if (!this.companyName || this.companyName.trim().length === 0) {
if (!this.companyEmail?.trim()) throw new Error('Company email is required'); throw new Error('Company name is required');
if (this.transitDays <= 0) throw new Error('Transit days must be positive'); }
if (this.freight.freightMinimum < 0) throw new Error('Freight minimum cannot be negative');
if (this.fob.fobHandling < 0) throw new Error('FOB handling cannot be negative'); if (!this.companyEmail || this.companyEmail.trim().length === 0) {
throw new Error('Company email is required');
}
if (this.volumeRange.minCBM < 0 || this.volumeRange.maxCBM < 0) {
throw new Error('Volume range cannot be negative');
}
if (this.volumeRange.minCBM > this.volumeRange.maxCBM) {
throw new Error('Min volume cannot be greater than max volume');
}
if (this.weightRange.minKG < 0 || this.weightRange.maxKG < 0) {
throw new Error('Weight range cannot be negative');
}
if (this.weightRange.minKG > this.weightRange.maxKG) {
throw new Error('Min weight cannot be greater than max weight');
}
if (this.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
if (this.pricing.pricePerCBM < 0 || this.pricing.pricePerKG < 0) {
throw new Error('Prices cannot be negative');
}
if (this.transitDays <= 0) {
throw new Error('Transit days must be positive');
}
if (this.currency !== 'USD' && this.currency !== 'EUR') {
throw new Error('Currency must be USD or EUR');
}
} }
/**
* Calculate total price for given volume and weight
*
* Business Logic:
* 1. Calculate volume-based price: volumeCBM * pricePerCBM
* 2. Calculate weight-based price: weightKG * pricePerKG
* 3. Take the maximum (freight class rule)
* 4. Add surcharges
*/
calculatePrice(volume: Volume): Money {
// Freight class rule: max(volume price, weight price)
const freightPrice = volume.calculateFreightPrice(
this.pricing.pricePerCBM,
this.pricing.pricePerKG
);
// Create Money object in the rate's currency
let totalPrice = Money.create(freightPrice, this.currency);
// Add surcharges in the same currency
const surchargeTotal = this.surcharges.getTotalAmount(this.currency);
totalPrice = totalPrice.add(surchargeTotal);
return totalPrice;
}
/**
* Get price in specific currency (USD or EUR)
*/
getPriceInCurrency(volume: Volume, targetCurrency: 'USD' | 'EUR'): Money {
const price = this.calculatePrice(volume);
// If already in target currency, return as-is
if (price.getCurrency() === targetCurrency) {
return price;
}
// Otherwise, use the pre-calculated base price in target currency
// and recalculate proportionally
const basePriceInPrimaryCurrency =
this.currency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
const basePriceInTargetCurrency =
targetCurrency === 'USD' ? this.pricing.basePriceUSD : this.pricing.basePriceEUR;
// Calculate conversion ratio
const ratio = basePriceInTargetCurrency.getAmount() / basePriceInPrimaryCurrency.getAmount();
// Apply ratio to calculated price
const convertedAmount = price.getAmount() * ratio;
return Money.create(convertedAmount, targetCurrency);
}
/**
* Check if rate is valid for a specific date
*/
isValidForDate(date: Date): boolean { isValidForDate(date: Date): boolean {
return this.validity.contains(date); return this.validity.contains(date);
} }
/**
* Check if rate is currently valid (today is within validity period)
*/
isCurrentlyValid(): boolean { isCurrentlyValid(): boolean {
return this.validity.isCurrentRange(); return this.validity.isCurrentRange();
} }
matchesRoute(origin: PortCode, destination: PortCode): boolean { /**
return this.originCode.equals(origin) && this.destinationCode.equals(destination); * Check if volume and weight match this rate's range
*/
matchesVolume(volume: Volume): boolean {
return volume.isWithinRange(
this.volumeRange.minCBM,
this.volumeRange.maxCBM,
this.weightRange.minKG,
this.weightRange.maxKG
);
} }
isDgAccepted(): boolean { /**
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED'; * Check if pallet count matches
} * 0 means "any pallet count" (flexible)
* Otherwise must match exactly or be within range
isDgOnRequest(): boolean { */
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST'; matchesPalletCount(palletCount: number): boolean {
} // If rate has 0 pallets, it's flexible
if (this.palletCount === 0) {
isDirectRoute(): boolean { return true;
return this.routing.trim().toLowerCase() === 'direct';
}
getFrequencyScore(): number {
switch (this.frequency) {
case 'Weekly':
return 4;
case 'Bi-Weekly':
return 3;
case 'Bi-Monthly':
return 2;
case 'Monthly':
return 1;
default:
return 2;
} }
// Otherwise must match exactly
return this.palletCount === palletCount;
} }
/**
* Check if rate matches a specific route
*/
matchesRoute(origin: PortCode, destination: PortCode): boolean {
return this.origin.equals(origin) && this.destination.equals(destination);
}
/**
* Check if rate has separate surcharges
*/
hasSurcharges(): boolean {
return !this.surcharges.isEmpty();
}
/**
* Get surcharge details as formatted string
*/
getSurchargeDetails(): string {
return this.surcharges.getDetails();
}
/**
* Check if this is an "all-in" rate (no separate surcharges)
*/
isAllInPrice(): boolean {
return this.surcharges.isEmpty();
}
/**
* Get route description
*/
getRouteDescription(): string { getRouteDescription(): string {
return `${this.originCode.getValue()}${this.destinationCode.getValue()}`; return `${this.origin.getValue()}${this.destination.getValue()}`;
} }
/**
* Get company and route summary
*/
getSummary(): string { getSummary(): string {
return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`; return `${this.companyName}: ${this.getRouteDescription()} (${this.containerType.getValue()})`;
} }

View File

@ -10,8 +10,6 @@
* - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER) * - Type must be valid (FREIGHT_FORWARDER, CARRIER, SHIPPER)
*/ */
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
export enum OrganizationType { export enum OrganizationType {
FREIGHT_FORWARDER = 'FREIGHT_FORWARDER', FREIGHT_FORWARDER = 'FREIGHT_FORWARDER',
CARRIER = 'CARRIER', CARRIER = 'CARRIER',
@ -49,7 +47,6 @@ export interface OrganizationProps {
siret?: string; siret?: string;
siretVerified: boolean; siretVerified: boolean;
statusBadge: 'none' | 'silver' | 'gold' | 'platinium'; statusBadge: 'none' | 'silver' | 'gold' | 'platinium';
defaultLanguage: Locale;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
isActive: boolean; isActive: boolean;
@ -66,13 +63,9 @@ export class Organization {
* Factory method to create a new Organization * Factory method to create a new Organization
*/ */
static create( static create(
props: Omit< props: Omit<OrganizationProps, 'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge'> & {
OrganizationProps,
'createdAt' | 'updatedAt' | 'siretVerified' | 'statusBadge' | 'defaultLanguage'
> & {
siretVerified?: boolean; siretVerified?: boolean;
statusBadge?: 'none' | 'silver' | 'gold' | 'platinium'; statusBadge?: 'none' | 'silver' | 'gold' | 'platinium';
defaultLanguage?: Locale;
} }
): Organization { ): Organization {
const now = new Date(); const now = new Date();
@ -101,7 +94,6 @@ export class Organization {
...props, ...props,
siretVerified: props.siretVerified ?? false, siretVerified: props.siretVerified ?? false,
statusBadge: props.statusBadge ?? 'none', statusBadge: props.statusBadge ?? 'none',
defaultLanguage: props.defaultLanguage ?? DEFAULT_LOCALE,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -196,15 +188,6 @@ export class Organization {
return this.props.isActive; 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 // Business methods
isCarrier(): boolean { isCarrier(): boolean {
return this.props.type === OrganizationType.CARRIER; return this.props.type === OrganizationType.CARRIER;

View File

@ -10,8 +10,6 @@
* - Role-based access control (Admin, Manager, User, Viewer) * - Role-based access control (Admin, Manager, User, Viewer)
*/ */
import { DEFAULT_LOCALE, Locale } from '../value-objects/locale.vo';
export enum UserRole { export enum UserRole {
ADMIN = 'ADMIN', // Full system access ADMIN = 'ADMIN', // Full system access
MANAGER = 'MANAGER', // Manage bookings and users within organization MANAGER = 'MANAGER', // Manage bookings and users within organization
@ -32,7 +30,6 @@ export interface UserProps {
isEmailVerified: boolean; isEmailVerified: boolean;
isActive: boolean; isActive: boolean;
lastLoginAt?: Date; lastLoginAt?: Date;
preferredLanguage: Locale;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@ -50,13 +47,8 @@ export class User {
static create( static create(
props: Omit< props: Omit<
UserProps, UserProps,
| 'createdAt' 'createdAt' | 'updatedAt' | 'isEmailVerified' | 'isActive' | 'lastLoginAt'
| 'updatedAt' >
| 'isEmailVerified'
| 'isActive'
| 'lastLoginAt'
| 'preferredLanguage'
> & { preferredLanguage?: Locale }
): User { ): User {
const now = new Date(); const now = new Date();
@ -67,7 +59,6 @@ export class User {
return new User({ return new User({
...props, ...props,
preferredLanguage: props.preferredLanguage ?? DEFAULT_LOCALE,
isEmailVerified: false, isEmailVerified: false,
isActive: true, isActive: true,
createdAt: now, createdAt: now,
@ -151,15 +142,6 @@ export class User {
return this.props.updatedAt; 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 // Business methods
has2FAEnabled(): boolean { has2FAEnabled(): boolean {
return !!this.props.totpSecret; return !!this.props.totpSecret;

View File

@ -1,30 +0,0 @@
/**
* 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<string, string | number | boolean | undefined | null>;
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);
}
}

View File

@ -4,7 +4,6 @@
* All domain exceptions for the Xpeditis platform * All domain exceptions for the Xpeditis platform
*/ */
export * from './domain.exception';
export * from './invalid-port-code.exception'; export * from './invalid-port-code.exception';
export * from './invalid-rate-quote.exception'; export * from './invalid-rate-quote.exception';
export * from './carrier-timeout.exception'; export * from './carrier-timeout.exception';

View File

@ -1,13 +1,13 @@
/** /**
* PortNotFoundException * PortNotFoundException
* *
* Thrown when a port is not found in the database. * Thrown when a port is not found in the database
*/ */
import { DomainException } from './domain.exception'; export class PortNotFoundException extends Error {
export class PortNotFoundException extends DomainException {
constructor(public readonly portCode: string) { constructor(public readonly portCode: string) {
super('error.PORT_NOT_FOUND', { portCode }, `Port not found: ${portCode}`, 404); super(`Port not found: ${portCode}`);
this.name = 'PortNotFoundException';
Object.setPrototypeOf(this, PortNotFoundException.prototype);
} }
} }

View File

@ -1,73 +1,160 @@
import { CsvRate } from '../../entities/csv-rate.entity'; import { CsvRate } from '../../entities/csv-rate.entity';
import { ServiceLevel } from '../../services/rate-offer-generator.service'; import { ServiceLevel } from '../../services/rate-offer-generator.service';
import { PriceBreakdown } from '../../services/csv-rate-price-calculator.service';
export { PriceBreakdown };
/** /**
* Filters for narrowing CSV rate search results. * Advanced Rate Search Filters
* Volume/weight range filters removed new schema has no per-rate volume limits. *
* Filters for narrowing down rate search results
*/ */
export interface RateSearchFilters { export interface RateSearchFilters {
companies?: string[]; // Company filters
companies?: string[]; // List of company names to include
// Price filter (applied to totalPriceForSorting) // Volume/Weight filters
minVolumeCBM?: number;
maxVolumeCBM?: number;
minWeightKG?: number;
maxWeightKG?: number;
palletCount?: number; // Exact pallet count (0 = any)
// Price filters
minPrice?: number; minPrice?: number;
maxPrice?: number; maxPrice?: number;
currency?: 'USD' | 'EUR'; currency?: 'USD' | 'EUR'; // Preferred currency for filtering
// Transit filter // Transit filters
minTransitDays?: number; minTransitDays?: number;
maxTransitDays?: number; maxTransitDays?: number;
// Route filter // Container type filters
onlyDirect?: boolean; // Only show "Direct" routing containerTypes?: string[]; // e.g., ['LCL', '20DRY', '40HC']
// Container type filter // Surcharge filters
containerTypes?: string[]; onlyAllInPrices?: boolean; // Only show rates without separate surcharges
// Date filter // Date filters
departureDate?: Date; departureDate?: Date; // Filter by validity for specific date
// Service level filter (for offers endpoint) // Service level filter
serviceLevels?: ServiceLevel[]; serviceLevels?: ServiceLevel[]; // Filter by service level (RAPID, STANDARD, ECONOMIC)
// DG filter
excludeNonDgRoutes?: boolean; // Only show DG-accepted routes
} }
/**
* CSV Rate Search Input
*
* Parameters for searching rates in CSV system
*/
export interface CsvRateSearchInput { export interface CsvRateSearchInput {
origin: string; // UN/LOCODE origin: string; // Port code (UN/LOCODE)
destination: string; // UN/LOCODE destination: string; // Port code (UN/LOCODE)
volumeCBM: number; volumeCBM: number; // Volume in cubic meters
weightKG: number; weightKG: number; // Weight in kilograms
containerType?: string; palletCount?: number; // Number of pallets (0 if none)
containerType?: string; // Optional container type filter
filters?: RateSearchFilters; // Advanced filters
// Service requirements for price calculation
hasDangerousGoods?: boolean; hasDangerousGoods?: boolean;
filters?: RateSearchFilters; requiresSpecialHandling?: boolean;
requiresTailgate?: boolean;
requiresStraps?: boolean;
requiresThermalCover?: boolean;
hasRegulatedProducts?: boolean;
requiresAppointment?: boolean;
} }
/**
* Surcharge Item - Individual fee or charge
*/
export interface SurchargeItem {
code: string;
description: string;
amount: number;
type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
}
/**
* Price Breakdown - Detailed pricing calculation
*/
export interface PriceBreakdown {
basePrice: number;
volumeCharge: number;
weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
/**
* CSV Rate Search Result
*
* Single rate result with calculated price
*/
export interface CsvRateSearchResult { export interface CsvRateSearchResult {
rate: CsvRate; rate: CsvRate;
priceBreakdown: PriceBreakdown; calculatedPrice: {
usd: number;
eur: number;
primaryCurrency: string;
};
priceBreakdown: PriceBreakdown; // Detailed price calculation
source: 'CSV'; source: 'CSV';
matchScore: number; matchScore: number; // 0-100, how well it matches filters
serviceLevel?: ServiceLevel; serviceLevel?: ServiceLevel; // Service level (RAPID, STANDARD, ECONOMIC) if offers are generated
priceMultiplier?: number; originalPrice?: {
originalTransitDays?: number; usd: number;
adjustedTransitDays?: number; eur: number;
}; // Original price before service level adjustment
originalTransitDays?: number; // Original transit days before service level adjustment
adjustedTransitDays?: number; // Adjusted transit days (for service level offers)
} }
/**
* CSV Rate Search Output
*
* Results from CSV rate search
*/
export interface CsvRateSearchOutput { export interface CsvRateSearchOutput {
results: CsvRateSearchResult[]; results: CsvRateSearchResult[];
totalResults: number; totalResults: number;
searchedFiles: string[]; searchedFiles: string[]; // CSV files searched
searchedAt: Date; searchedAt: Date;
appliedFilters: RateSearchFilters; appliedFilters: RateSearchFilters;
} }
/**
* Search CSV Rates Port (Input Port)
*
* Use case for searching rates in CSV-based system
* Supports advanced filters for precise rate matching
*/
export interface SearchCsvRatesPort { export interface SearchCsvRatesPort {
/**
* Execute CSV rate search with filters
* @param input - Search parameters and filters
* @returns Matching rates with calculated prices
*/
execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>; execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Execute CSV rate search with service level offers generation
* Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
* @param input - Search parameters and filters
* @returns Matching rates with 3 service level variants each
*/
executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>; executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput>;
/**
* Get available companies in CSV system
* @returns List of company names that have CSV rates
*/
getAvailableCompanies(): Promise<string[]>; getAvailableCompanies(): Promise<string[]>;
/**
* Get available container types in CSV system
* @returns List of container types available
*/
getAvailableContainerTypes(): Promise<string[]>; getAvailableContainerTypes(): Promise<string[]>;
} }

View File

@ -3,152 +3,217 @@ import { CsvRate } from '../entities/csv-rate.entity';
export interface PriceCalculationParams { export interface PriceCalculationParams {
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
hasDangerousGoods?: boolean; palletCount: number;
hasDangerousGoods: boolean;
requiresSpecialHandling: boolean;
requiresTailgate: boolean;
requiresStraps: boolean;
requiresThermalCover: boolean;
hasRegulatedProducts: boolean;
requiresAppointment: boolean;
} }
export interface FobBreakdown {
documentation: number;
isps: number;
handling: number;
solas: number;
customs: number;
ams_aci: number;
isf5: number;
dgAdmin: number;
}
export type DgSurchargeStatus = 'computed' | 'on_request' | 'not_accepted';
export interface PriceBreakdown { export interface PriceBreakdown {
// Freight (in freightCurrency) basePrice: number;
freightCharge: number; volumeCharge: number;
freightCurrency: string; weightCharge: number;
palletCharge: number;
surcharges: SurchargeItem[];
totalSurcharges: number;
totalPrice: number;
currency: string;
}
// FOB charges (in fobCurrency) export interface SurchargeItem {
fobFixed: number; // doc + ISPS + solas + customs + AMS_ACI + ISF5 code: string;
fobHandling: number; description: string;
fobDG: number; // fobDGAdmin only if DG amount: number;
fobCurrency: string; type: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE';
fobBreakdown: FobBreakdown;
// DG surcharge (fobCurrency or dgSurchargeCurrency)
dgSurchargeAmount: number | null; // null when on_request or not_accepted
dgSurchargeCurrency: string;
dgSurchargeStatus: DgSurchargeStatus;
// Totals (each in their own currency)
totalFreight: number; // = freightCharge in freightCurrency
totalFob: number; // = fobFixed + fobHandling + fobDG + dgSurcharge in fobCurrency
// Used for sorting/comparison only — naive sum treating both currencies as equal
// Callers should be aware of potential currency mismatch
totalPriceForSorting: number;
primaryCurrency: string;
} }
/** /**
* Calculates price for a CSV rate given volume and weight. * Service de calcul de prix pour les tarifs CSV
* * Calcule le prix total basé sur le volume, poids, palettes et services additionnels
* Formula:
* Fret = max(freightRatePerCBM × V, freightMinimum)
* Handling = max(fobHandling × max(V, W_tonnes), fobHandlingMinimum) [if unit=W]
* = max(fobHandling × V, fobHandlingMinimum) [if unit=UP]
* FOB fixed = doc + ISPS + solas + customs + AMS_ACI + ISF5
* Total = Fret (freightCurrency) + FOB_fixed + Handling (fobCurrency)
*/ */
export class CsvRatePriceCalculatorService { export class CsvRatePriceCalculatorService {
/**
* Calcule le prix total pour un tarif CSV donné
*/
calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown { calculatePrice(rate: CsvRate, params: PriceCalculationParams): PriceBreakdown {
const V = params.volumeCBM; // 1. Prix de base
const W = params.weightKG / 1000; // convert KG → tonnes for W unit const basePrice = rate.pricing.basePriceUSD.getAmount();
const isDG = params.hasDangerousGoods ?? false;
// 1. Freight charge // 2. Frais au volume (USD par CBM)
const freightCharge = const volumeCharge = rate.pricing.pricePerCBM * params.volumeCBM;
rate.freight.freightRatePerCBM > 0
? Math.max(rate.freight.freightRatePerCBM * V, rate.freight.freightMinimum)
: rate.freight.freightMinimum;
// 2. Handling — "W" = tonne revenue (max of CBM and tonnes), "UP" = per CBM // 3. Frais au poids (USD par KG)
const handlingBase = rate.fob.fobHandlingUnit === 'W' ? Math.max(V, W) : V; const weightCharge = rate.pricing.pricePerKG * params.weightKG;
const fobHandling = Math.max(rate.fob.fobHandling * handlingBase, rate.fob.fobHandlingMinimum);
// 3. FOB fixed charges // 4. Frais de palettes (25 USD par palette)
const fobFixed = const palletCharge = params.palletCount * 25;
rate.fob.fobDocumentation +
rate.fob.fobISPS +
rate.fob.fobSolas +
rate.fob.fobCustoms +
rate.fob.fobAMS_ACI +
rate.fob.fobISF5;
// 4. DG admin (FOB currency, only if DG) // 5. Surcharges standard du CSV
const fobDG = isDG ? rate.fob.fobDGAdmin : 0; const standardSurcharges = this.parseStandardSurcharges(rate.getSurchargeDetails(), params);
// 5. DG surcharge (own currency, only if DG) // 6. Surcharges additionnelles basées sur les services
let dgSurchargeAmount: number | null = null; const additionalSurcharges = this.calculateAdditionalSurcharges(params);
let dgSurchargeStatus: DgSurchargeStatus = 'computed';
if (isDG) { // 7. Total des surcharges
const dgRate = rate.dgSurcharge.dgSurchargeRate; const allSurcharges = [...standardSurcharges, ...additionalSurcharges];
if (dgRate === 'NOT ACCEPTED') { const totalSurcharges = allSurcharges.reduce((sum, s) => sum + s.amount, 0);
dgSurchargeStatus = 'not_accepted';
} else if (dgRate === 'ON REQUEST') {
dgSurchargeStatus = 'on_request';
} else {
dgSurchargeStatus = 'computed';
const dgNum = typeof dgRate === 'number' ? dgRate : parseFloat(String(dgRate));
let rawDG = 0;
switch (rate.dgSurcharge.dgSurchargeUnit) {
case 'UP':
rawDG = dgNum * V;
break;
case 'LS':
rawDG = dgNum;
break;
case '%':
rawDG = freightCharge * (dgNum / 100);
break;
}
const dgMin =
typeof rate.dgSurcharge.dgSurchargeMin === 'number' ? rate.dgSurcharge.dgSurchargeMin : 0;
dgSurchargeAmount = Math.max(rawDG, dgMin);
}
}
// 6. Total FOB (in fobCurrency) // 8. Prix total
const totalFob = fobFixed + fobHandling + fobDG + (dgSurchargeAmount ?? 0); const totalPrice = basePrice + volumeCharge + weightCharge + palletCharge + totalSurcharges;
// 7. Naive sum for sorting (ignores currency differences)
const totalPriceForSorting = freightCharge + totalFob;
return { return {
freightCharge: round2(freightCharge), basePrice,
freightCurrency: rate.freight.freightCurrency, volumeCharge,
fobFixed: round2(fobFixed), weightCharge,
fobHandling: round2(fobHandling), palletCharge,
fobDG: round2(fobDG), surcharges: allSurcharges,
fobCurrency: rate.fob.fobCurrency, totalSurcharges,
fobBreakdown: { totalPrice: Math.round(totalPrice * 100) / 100, // Arrondi à 2 décimales
documentation: rate.fob.fobDocumentation, currency: rate.currency || 'USD',
isps: rate.fob.fobISPS,
handling: round2(fobHandling),
solas: rate.fob.fobSolas,
customs: rate.fob.fobCustoms,
ams_aci: rate.fob.fobAMS_ACI,
isf5: rate.fob.fobISF5,
dgAdmin: isDG ? rate.fob.fobDGAdmin : 0,
},
dgSurchargeAmount: dgSurchargeAmount !== null ? round2(dgSurchargeAmount) : null,
dgSurchargeCurrency: rate.dgSurcharge.dgSurchargeCurrency,
dgSurchargeStatus,
totalFreight: round2(freightCharge),
totalFob: round2(totalFob),
totalPriceForSorting: round2(totalPriceForSorting),
primaryCurrency: rate.freight.freightCurrency,
}; };
} }
}
function round2(n: number): number { /**
return Math.round(n * 100) / 100; * Parse les surcharges standard du format CSV
* Format: "DOC:10 | ISPS:7 | HANDLING:20 W | DG_FEE:65"
*/
private parseStandardSurcharges(
surchargeDetails: string | null,
params: PriceCalculationParams
): SurchargeItem[] {
if (!surchargeDetails) {
return [];
}
const surcharges: SurchargeItem[] = [];
const items = surchargeDetails.split('|').map(s => s.trim());
for (const item of items) {
const match = item.match(/^([A-Z_]+):(\d+(?:\.\d+)?)\s*([WP%]?)$/);
if (!match) continue;
const [, code, amountStr, type] = match;
let amount = parseFloat(amountStr);
let surchargeType: 'FIXED' | 'PER_UNIT' | 'PERCENTAGE' = 'FIXED';
// Calcul selon le type
if (type === 'W') {
// Par poids (W = Weight)
amount = amount * params.weightKG;
surchargeType = 'PER_UNIT';
} else if (type === 'P') {
// Par palette
amount = amount * params.palletCount;
surchargeType = 'PER_UNIT';
} else if (type === '%') {
// Pourcentage (sera appliqué sur le total)
surchargeType = 'PERCENTAGE';
}
// Certaines surcharges ne s'appliquent que si certaines conditions sont remplies
if (code === 'DG_FEE' && !params.hasDangerousGoods) {
continue; // Skip DG fee si pas de marchandises dangereuses
}
surcharges.push({
code,
description: this.getSurchargeDescription(code),
amount: Math.round(amount * 100) / 100,
type: surchargeType,
});
}
return surcharges;
}
/**
* Calcule les surcharges additionnelles basées sur les services demandés
*/
private calculateAdditionalSurcharges(params: PriceCalculationParams): SurchargeItem[] {
const surcharges: SurchargeItem[] = [];
if (params.requiresSpecialHandling) {
surcharges.push({
code: 'SPECIAL_HANDLING',
description: 'Manutention particulière',
amount: 75,
type: 'FIXED',
});
}
if (params.requiresTailgate) {
surcharges.push({
code: 'TAILGATE',
description: 'Hayon élévateur',
amount: 50,
type: 'FIXED',
});
}
if (params.requiresStraps) {
surcharges.push({
code: 'STRAPS',
description: 'Sangles de sécurité',
amount: 30,
type: 'FIXED',
});
}
if (params.requiresThermalCover) {
surcharges.push({
code: 'THERMAL_COVER',
description: 'Couverture thermique',
amount: 100,
type: 'FIXED',
});
}
if (params.hasRegulatedProducts) {
surcharges.push({
code: 'REGULATED_PRODUCTS',
description: 'Produits réglementés',
amount: 80,
type: 'FIXED',
});
}
if (params.requiresAppointment) {
surcharges.push({
code: 'APPOINTMENT',
description: 'Livraison sur rendez-vous',
amount: 40,
type: 'FIXED',
});
}
return surcharges;
}
/**
* Retourne la description d'un code de surcharge standard
*/
private getSurchargeDescription(code: string): string {
const descriptions: Record<string, string> = {
DOC: 'Documentation fee',
ISPS: 'ISPS Security',
HANDLING: 'Handling charges',
SOLAS: 'SOLAS VGM',
CUSTOMS: 'Customs clearance',
AMS_ACI: 'AMS/ACI filing',
DG_FEE: 'Dangerous goods fee',
BAF: 'Bunker Adjustment Factor',
CAF: 'Currency Adjustment Factor',
THC: 'Terminal Handling Charges',
BL_FEE: 'Bill of Lading fee',
TELEX_RELEASE: 'Telex release',
ORIGIN_CHARGES: 'Origin charges',
DEST_CHARGES: 'Destination charges',
};
return descriptions[code] || code.replace(/_/g, ' ');
}
} }

View File

@ -1,6 +1,7 @@
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { Volume } from '../value-objects/volume.vo';
import { import {
SearchCsvRatesPort, SearchCsvRatesPort,
CsvRateSearchInput, CsvRateSearchInput,
@ -10,8 +11,11 @@ import {
} from '@domain/ports/in/search-csv-rates.port'; } from '@domain/ports/in/search-csv-rates.port';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service'; import { CsvRatePriceCalculatorService } from './csv-rate-price-calculator.service';
import { RateOfferGeneratorService } from './rate-offer-generator.service'; import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.service';
/**
* Config Metadata Interface (to avoid circular dependency)
*/
interface CsvRateConfig { interface CsvRateConfig {
companyName: string; companyName: string;
csvFilePath: string; csvFilePath: string;
@ -21,10 +25,21 @@ interface CsvRateConfig {
}; };
} }
/**
* Config Repository Port (simplified interface)
*/
export interface CsvRateConfigRepositoryPort { export interface CsvRateConfigRepositoryPort {
findActiveConfigs(): Promise<CsvRateConfig[]>; findActiveConfigs(): Promise<CsvRateConfig[]>;
} }
/**
* CSV Rate Search Service
*
* Domain service implementing CSV rate search use case.
* Applies business rules for matching rates and filtering.
*
* Pure domain logic - no framework dependencies.
*/
export class CsvRateSearchService implements SearchCsvRatesPort { export class CsvRateSearchService implements SearchCsvRatesPort {
private readonly priceCalculator: CsvRatePriceCalculatorService; private readonly priceCalculator: CsvRatePriceCalculatorService;
private readonly offerGenerator: RateOfferGeneratorService; private readonly offerGenerator: RateOfferGeneratorService;
@ -39,39 +54,63 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async execute(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin); const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination); const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination);
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
} }
// Apply advanced filters
if (input.filters) { if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input); matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
} }
// Calculate prices and create results
const results: CsvRateSearchResult[] = matchingRates.map(rate => { const results: CsvRateSearchResult[] = matchingRates.map(rate => {
// Calculate detailed price breakdown
const priceBreakdown = this.priceCalculator.calculatePrice(rate, { const priceBreakdown = this.priceCalculator.calculatePrice(rate, {
volumeCBM: input.volumeCBM, volumeCBM: input.volumeCBM,
weightKG: input.weightKG, weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false, hasDangerousGoods: input.hasDangerousGoods ?? false,
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
requiresTailgate: input.requiresTailgate ?? false,
requiresStraps: input.requiresStraps ?? false,
requiresThermalCover: input.requiresThermalCover ?? false,
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
requiresAppointment: input.requiresAppointment ?? false,
}); });
return { return {
rate, rate,
calculatedPrice: {
usd: priceBreakdown.totalPrice,
eur: priceBreakdown.totalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown, priceBreakdown,
source: 'CSV' as const, source: 'CSV' as const,
matchScore: this.calculateMatchScore(rate), matchScore: this.calculateMatchScore(rate, input),
}; };
}); });
results.sort( // Sort by total price (ascending)
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting results.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
);
return { return {
results, results,
@ -83,67 +122,101 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
} }
/** /**
* Search with service level offers returns 3 variants per rate (ECONOMIC / STANDARD / RAPID). * Execute CSV rate search with service level offers generation
* Price multipliers (0.85 / 1.0 / 1.2) are applied to totalPriceForSorting. * Generates 3 offers (RAPID, STANDARD, ECONOMIC) for each matching rate
*/ */
async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> { async executeWithOffers(input: CsvRateSearchInput): Promise<CsvRateSearchOutput> {
const searchStartTime = new Date(); const searchStartTime = new Date();
// Parse and validate input
const origin = PortCode.create(input.origin); const origin = PortCode.create(input.origin);
const destination = PortCode.create(input.destination); const destination = PortCode.create(input.destination);
const volume = new Volume(input.volumeCBM, input.weightKG);
const palletCount = input.palletCount ?? 0;
// Load all CSV rates
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
let matchingRates = this.filterByRoute(allRates, origin, destination);
// Apply route and volume matching
let matchingRates = this.filterByRoute(allRates, origin, destination);
matchingRates = this.filterByVolume(matchingRates, volume);
matchingRates = this.filterByPalletCount(matchingRates, palletCount);
// Apply container type filter if specified
if (input.containerType) { if (input.containerType) {
const containerType = ContainerType.create(input.containerType); const containerType = ContainerType.create(input.containerType);
matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType)); matchingRates = matchingRates.filter(rate => rate.containerType.equals(containerType));
} }
// Apply advanced filters (before generating offers)
if (input.filters) { if (input.filters) {
matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, input); matchingRates = this.applyAdvancedFilters(matchingRates, input.filters, volume);
} }
// Filter eligible rates for offer generation
const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates); const eligibleRates = this.offerGenerator.filterEligibleRates(matchingRates);
// Generate 3 offers (RAPID, STANDARD, ECONOMIC) for each eligible rate
const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates); const allOffers = this.offerGenerator.generateOffersForRates(eligibleRates);
// Convert offers to search results
const results: CsvRateSearchResult[] = allOffers.map(offer => { const results: CsvRateSearchResult[] = allOffers.map(offer => {
// Calculate detailed price breakdown with adjusted prices
const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, { const priceBreakdown = this.priceCalculator.calculatePrice(offer.rate, {
volumeCBM: input.volumeCBM, volumeCBM: input.volumeCBM,
weightKG: input.weightKG, weightKG: input.weightKG,
palletCount: input.palletCount ?? 0,
hasDangerousGoods: input.hasDangerousGoods ?? false, hasDangerousGoods: input.hasDangerousGoods ?? false,
requiresSpecialHandling: input.requiresSpecialHandling ?? false,
requiresTailgate: input.requiresTailgate ?? false,
requiresStraps: input.requiresStraps ?? false,
requiresThermalCover: input.requiresThermalCover ?? false,
hasRegulatedProducts: input.hasRegulatedProducts ?? false,
requiresAppointment: input.requiresAppointment ?? false,
}); });
const multiplier = offer.priceMultiplier; // Apply service level price adjustment to the total price
const adjustedBreakdown = { const adjustedTotalPrice =
...priceBreakdown, priceBreakdown.totalPrice *
freightCharge: round2(priceBreakdown.freightCharge * multiplier), (offer.serviceLevel === ServiceLevel.RAPID
totalFreight: round2(priceBreakdown.totalFreight * multiplier), ? 1.2
totalFob: round2(priceBreakdown.totalFob * multiplier), : offer.serviceLevel === ServiceLevel.ECONOMIC
totalPriceForSorting: round2(priceBreakdown.totalPriceForSorting * multiplier), ? 0.85
}; : 1.0);
return { return {
rate: offer.rate, rate: offer.rate,
priceBreakdown: adjustedBreakdown, calculatedPrice: {
usd: adjustedTotalPrice,
eur: adjustedTotalPrice, // TODO: Add currency conversion
primaryCurrency: priceBreakdown.currency,
},
priceBreakdown: {
...priceBreakdown,
totalPrice: adjustedTotalPrice,
},
source: 'CSV' as const, source: 'CSV' as const,
matchScore: this.calculateMatchScore(offer.rate), matchScore: this.calculateMatchScore(offer.rate, input),
serviceLevel: offer.serviceLevel, serviceLevel: offer.serviceLevel,
priceMultiplier: offer.priceMultiplier, originalPrice: {
usd: offer.originalPriceUSD,
eur: offer.originalPriceEUR,
},
originalTransitDays: offer.originalTransitDays, originalTransitDays: offer.originalTransitDays,
adjustedTransitDays: offer.adjustedTransitDays, adjustedTransitDays: offer.adjustedTransitDays,
}; };
}); });
// Apply service level filter if specified
let filteredResults = results; let filteredResults = results;
if (input.filters?.serviceLevels?.length) { if (input.filters?.serviceLevels && input.filters.serviceLevels.length > 0) {
filteredResults = results.filter( filteredResults = results.filter(
r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel) r => r.serviceLevel && input.filters!.serviceLevels!.includes(r.serviceLevel)
); );
} }
filteredResults.sort( // Sort by total price (ascending) - ECONOMIC first, then STANDARD, then RAPID
(a, b) => a.priceBreakdown.totalPriceForSorting - b.priceBreakdown.totalPriceForSorting filteredResults.sort((a, b) => a.priceBreakdown.totalPrice - b.priceBreakdown.totalPrice);
);
return { return {
results: filteredResults, results: filteredResults,
@ -156,110 +229,197 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
async getAvailableCompanies(): Promise<string[]> { async getAvailableCompanies(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
return [...new Set(allRates.map(r => r.companyName))].sort(); const companies = new Set(allRates.map(rate => rate.companyName));
return Array.from(companies).sort();
} }
async getAvailableContainerTypes(): Promise<string[]> { async getAvailableContainerTypes(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
return [...new Set(allRates.map(r => r.containerType.getValue()))].sort(); const types = new Set(allRates.map(rate => rate.containerType.getValue()));
return Array.from(types).sort();
} }
/**
* Get all unique origin port codes from CSV rates
* Used to limit port selection to only those with available routes
*/
async getAvailableOrigins(): Promise<string[]> { async getAvailableOrigins(): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
return [...new Set(allRates.map(r => r.originCode.getValue()))].sort(); const origins = new Set(allRates.map(rate => rate.origin.getValue()));
return Array.from(origins).sort();
} }
/**
* Get all destination port codes available for a given origin
* Used to limit destination selection based on selected origin
*/
async getAvailableDestinations(origin: string): Promise<string[]> { async getAvailableDestinations(origin: string): Promise<string[]> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const originCode = PortCode.create(origin); const originCode = PortCode.create(origin);
return [
...new Set( const destinations = new Set(
allRates.filter(r => r.originCode.equals(originCode)).map(r => r.destinationCode.getValue()) allRates
), .filter(rate => rate.origin.equals(originCode))
].sort(); .map(rate => rate.destination.getValue())
);
return Array.from(destinations).sort();
} }
/**
* Get all available routes (origin-destination pairs) from CSV rates
* Returns a map of origin codes to their available destination codes
*/
async getAvailableRoutes(): Promise<Map<string, string[]>> { async getAvailableRoutes(): Promise<Map<string, string[]>> {
const allRates = await this.loadAllRates(); const allRates = await this.loadAllRates();
const routeMap = new Map<string, Set<string>>(); const routeMap = new Map<string, Set<string>>();
allRates.forEach(rate => { allRates.forEach(rate => {
const origin = rate.originCode.getValue(); const origin = rate.origin.getValue();
const destination = rate.destinationCode.getValue(); const destination = rate.destination.getValue();
if (!routeMap.has(origin)) routeMap.set(origin, new Set());
if (!routeMap.has(origin)) {
routeMap.set(origin, new Set());
}
routeMap.get(origin)!.add(destination); routeMap.get(origin)!.add(destination);
}); });
// Convert Sets to sorted arrays
const result = new Map<string, string[]>(); const result = new Map<string, string[]>();
routeMap.forEach((destinations, origin) => { routeMap.forEach((destinations, origin) => {
result.set(origin, [...destinations].sort()); result.set(origin, Array.from(destinations).sort());
}); });
return result; return result;
} }
/**
* Load all rates from all CSV files
*/
private async loadAllRates(): Promise<CsvRate[]> { private async loadAllRates(): Promise<CsvRate[]> {
// If config repository is available, load rates with emails and company names from configs
if (this.configRepository) { if (this.configRepository) {
const configs = await this.configRepository.findActiveConfigs(); const configs = await this.configRepository.findActiveConfigs();
const ratePromises = configs.map(config => {
const email = config.metadata?.companyEmail || 'bookings@example.com';
// Pass company name from config to override CSV column value
return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
});
if (configs.length > 0) { // Use allSettled to handle missing files gracefully
const results = await Promise.allSettled( const results = await Promise.allSettled(ratePromises);
configs.map(config => { const rateArrays = results
const email = config.metadata?.companyEmail || 'bookings@example.com'; .filter(
return this.csvRateLoader.loadRatesFromCsv( (result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
config.csvFilePath, )
email, .map(result => result.value);
config.companyName
); // Log any failed file loads
}) const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) {
console.warn(
`Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
); );
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.warn(`Failed to load ${failures.length} CSV files from database configs`);
}
return results
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
.flatMap(r => r.value);
} }
// DB has no active configs — fall through to local CSV files return rateArrays.flat();
console.warn('No active CSV rate configs in database, loading from local CSV files');
} }
// Fallback: load files without email (use default)
const files = await this.csvRateLoader.getAvailableCsvFiles(); const files = await this.csvRateLoader.getAvailableCsvFiles();
const results = await Promise.allSettled( const ratePromises = files.map(file =>
files.map(file => this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')) this.csvRateLoader.loadRatesFromCsv(file, 'bookings@example.com')
); );
return results // Use allSettled here too for consistency
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled') const results = await Promise.allSettled(ratePromises);
.flatMap(r => r.value); const rateArrays = results
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
return rateArrays.flat();
} }
/**
* Filter rates by route (origin/destination)
*/
private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] { private filterByRoute(rates: CsvRate[], origin: PortCode, destination: PortCode): CsvRate[] {
return rates.filter(rate => rate.matchesRoute(origin, destination)); return rates.filter(rate => rate.matchesRoute(origin, destination));
} }
/**
* Filter rates by volume/weight range
*/
private filterByVolume(rates: CsvRate[], volume: Volume): CsvRate[] {
return rates.filter(rate => rate.matchesVolume(volume));
}
/**
* Filter rates by pallet count
*/
private filterByPalletCount(rates: CsvRate[], palletCount: number): CsvRate[] {
return rates.filter(rate => rate.matchesPalletCount(palletCount));
}
/**
* Apply advanced filters to rate list
*/
private applyAdvancedFilters( private applyAdvancedFilters(
rates: CsvRate[], rates: CsvRate[],
filters: RateSearchFilters, filters: RateSearchFilters,
input: CsvRateSearchInput volume: Volume
): CsvRate[] { ): CsvRate[] {
let filtered = rates; let filtered = rates;
if (filters.companies?.length) { // Company filter
if (filters.companies && filters.companies.length > 0) {
filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName)); filtered = filtered.filter(rate => filters.companies!.includes(rate.companyName));
} }
if (filters.onlyDirect) { // Volume CBM filter
filtered = filtered.filter(rate => rate.isDirectRoute()); if (filters.minVolumeCBM !== undefined) {
filtered = filtered.filter(rate => rate.volumeRange.maxCBM >= filters.minVolumeCBM!);
}
if (filters.maxVolumeCBM !== undefined) {
filtered = filtered.filter(rate => rate.volumeRange.minCBM <= filters.maxVolumeCBM!);
} }
if (filters.excludeNonDgRoutes) { // Weight KG filter
filtered = filtered.filter(rate => rate.isDgAccepted()); if (filters.minWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.maxKG >= filters.minWeightKG!);
}
if (filters.maxWeightKG !== undefined) {
filtered = filtered.filter(rate => rate.weightRange.minKG <= filters.maxWeightKG!);
} }
// Pallet count filter
if (filters.palletCount !== undefined) {
filtered = filtered.filter(rate => rate.matchesPalletCount(filters.palletCount!));
}
// Price filter (calculate price first)
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
const currency = filters.currency || 'USD';
filtered = filtered.filter(rate => {
const price = rate.getPriceInCurrency(volume, currency);
const amount = price.getAmount();
if (filters.minPrice !== undefined && amount < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && amount > filters.maxPrice) {
return false;
}
return true;
});
}
// Transit days filter
if (filters.minTransitDays !== undefined) { if (filters.minTransitDays !== undefined) {
filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!); filtered = filtered.filter(rate => rate.transitDays >= filters.minTransitDays!);
} }
@ -267,55 +427,52 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!); filtered = filtered.filter(rate => rate.transitDays <= filters.maxTransitDays!);
} }
if (filters.containerTypes?.length) { // Container type filter
if (filters.containerTypes && filters.containerTypes.length > 0) {
filtered = filtered.filter(rate => filtered = filtered.filter(rate =>
filters.containerTypes!.includes(rate.containerType.getValue()) filters.containerTypes!.includes(rate.containerType.getValue())
); );
} }
if (filters.departureDate) { // All-in prices only filter
filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!)); if (filters.onlyAllInPrices) {
filtered = filtered.filter(rate => rate.isAllInPrice());
} }
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) { // Departure date / validity filter
filtered = filtered.filter(rate => { if (filters.departureDate) {
const bd = this.priceCalculator.calculatePrice(rate, { filtered = filtered.filter(rate => rate.isValidForDate(filters.departureDate!));
volumeCBM: input.volumeCBM,
weightKG: input.weightKG,
hasDangerousGoods: input.hasDangerousGoods ?? false,
});
if (filters.minPrice !== undefined && bd.totalPriceForSorting < filters.minPrice) {
return false;
}
if (filters.maxPrice !== undefined && bd.totalPriceForSorting > filters.maxPrice) {
return false;
}
return true;
});
} }
return filtered; return filtered;
} }
/** /**
* Score (0100) based on routing type, departure frequency, and rate validity. * Calculate match score (0-100) based on how well rate matches input
* Higher = better match. * Higher score = better match
*/ */
private calculateMatchScore(rate: CsvRate): number { private calculateMatchScore(rate: CsvRate, input: CsvRateSearchInput): number {
let score = 100; let score = 100;
// Direct route bonus // Reduce score if volume/weight is near boundaries
if (rate.isDirectRoute()) { const volumeUtilization =
score += 10; (input.volumeCBM - rate.volumeRange.minCBM) /
} else { (rate.volumeRange.maxCBM - rate.volumeRange.minCBM);
if (volumeUtilization < 0.2 || volumeUtilization > 0.8) {
score -= 10; // Near boundaries
}
// Reduce score if pallet count doesn't match exactly
if (rate.palletCount !== 0 && input.palletCount !== rate.palletCount) {
score -= 5; score -= 5;
} }
// Frequency bonus (Weekly = best) // Increase score for all-in prices (simpler for customers)
const freqScore = rate.getFrequencyScore(); // 14 if (rate.isAllInPrice()) {
score += (freqScore - 2) * 5; // Weekly: +10, Bi-Weekly: +5, Bi-Monthly: 0, Monthly: -5 score += 5;
}
// Validity penalty // Reduce score for rates expiring soon
const daysUntilExpiry = Math.floor( const daysUntilExpiry = Math.floor(
(rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24) (rate.validity.getEndDate().getTime() - Date.now()) / (1000 * 60 * 60 * 24)
); );
@ -328,7 +485,3 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
return Math.max(0, Math.min(100, score)); return Math.max(0, Math.min(100, score));
} }
} }
function round2(n: number): number {
return Math.round(n * 100) / 100;
}

View File

@ -2,8 +2,16 @@ import { RateOfferGeneratorService, ServiceLevel } from './rate-offer-generator.
import { CsvRate } from '../entities/csv-rate.entity'; import { CsvRate } from '../entities/csv-rate.entity';
import { PortCode } from '../value-objects/port-code.vo'; import { PortCode } from '../value-objects/port-code.vo';
import { ContainerType } from '../value-objects/container-type.vo'; import { ContainerType } from '../value-objects/container-type.vo';
import { DateRange } from '../value-objects/date-range.vo'; import { Money } from '../value-objects/money.vo';
/**
* Test Suite for Rate Offer Generator Service
*
* Vérifie que:
* - RAPID est le plus cher ET le plus rapide
* - ECONOMIC est le moins cher ET le plus lent
* - STANDARD est au milieu en prix et transit time
*/
describe('RateOfferGeneratorService', () => { describe('RateOfferGeneratorService', () => {
let service: RateOfferGeneratorService; let service: RateOfferGeneratorService;
let mockRate: CsvRate; let mockRate: CsvRate;
@ -11,226 +19,415 @@ describe('RateOfferGeneratorService', () => {
beforeEach(() => { beforeEach(() => {
service = new RateOfferGeneratorService(); service = new RateOfferGeneratorService();
// Mock minimal CsvRate compatible with new schema // Créer un tarif de base pour les tests
// Prix: 1000 USD / 900 EUR, Transit: 20 jours
mockRate = { mockRate = {
companyName: 'Test Carrier', companyName: 'Test Carrier',
companyEmail: 'test@carrier.com', companyEmail: 'test@carrier.com',
originCFS: 'Fos Sur Mer', origin: PortCode.create('FRPAR'),
originCode: PortCode.create('FRFOS'), destination: PortCode.create('USNYC'),
portOfLoading: 'FOS SUR MER',
routing: 'Direct',
destinationCFS: 'New York',
destinationCode: PortCode.create('USNYC'),
destinationCountry: 'USA',
containerType: ContainerType.create('LCL'), containerType: ContainerType.create('LCL'),
freight: { volumeRange: { minCBM: 1, maxCBM: 10 },
freightCurrency: 'USD', weightRange: { minKG: 100, maxKG: 5000 },
freightRatePerCBM: 50, palletCount: 0,
freightMinimum: 500, pricing: {
pricePerCBM: 100,
pricePerKG: 0.5,
basePriceUSD: Money.create(1000, 'USD'),
basePriceEUR: Money.create(900, 'EUR'),
}, },
fob: { currency: 'USD',
fobCurrency: 'EUR', hasSurcharges: false,
fobDocumentation: 55, surchargeBAF: null,
fobISPS: 18, surchargeCAF: null,
fobHandling: 22, surchargeDetails: null,
fobHandlingUnit: 'W',
fobHandlingMinimum: 110,
fobSolas: 15,
fobCustoms: 85,
fobAMS_ACI: 35,
fobISF5: 0,
fobDGAdmin: 50,
},
dgSurcharge: {
dgSurchargeCurrency: 'EUR',
dgSurchargeRate: 20,
dgSurchargeUnit: 'UP',
dgSurchargeMin: 50,
},
remarks: '',
frequency: 'Weekly',
transitDays: 20, transitDays: 20,
validity: DateRange.create(new Date('2026-01-01'), new Date('2026-12-31'), true), validity: {
getStartDate: () => new Date('2024-01-01'),
getEndDate: () => new Date('2024-12-31'),
},
isValidForDate: () => true, isValidForDate: () => true,
isCurrentlyValid: () => true,
matchesRoute: () => true, matchesRoute: () => true,
isDgAccepted: () => true, matchesVolume: () => true,
isDgOnRequest: () => false, matchesPalletCount: () => true,
isDirectRoute: () => true, getPriceInCurrency: () => Money.create(1000, 'USD'),
getFrequencyScore: () => 4, isAllInPrice: () => true,
getRouteDescription: () => 'FRFOS → USNYC', getSurchargeDetails: () => null,
getSummary: () => 'Test Carrier: FRFOS → USNYC',
toString: () => 'Test Carrier: FRFOS → USNYC',
} as any; } as any;
}); });
describe('generateOffers', () => { describe('generateOffers', () => {
it('generates exactly 3 offers (RAPID, STANDARD, ECONOMIC)', () => { it('devrait générer exactement 3 offres (RAPID, STANDARD, ECONOMIC)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
expect(offers).toHaveLength(3); expect(offers).toHaveLength(3);
expect(offers.map(o => o.serviceLevel)).toEqual( expect(offers.map(o => o.serviceLevel)).toEqual(
expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC]) expect.arrayContaining([ServiceLevel.RAPID, ServiceLevel.STANDARD, ServiceLevel.ECONOMIC])
); );
}); });
it('ECONOMIC has the lowest price multiplier (0.85)', () => { it('ECONOMIC doit être le moins cher', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
expect(economic.priceMultiplier).toBe(0.85); const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
expect(economic.priceAdjustmentPercent).toBe(-15); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le prix le plus bas
expect(economic!.adjustedPriceUSD).toBeLessThan(standard!.adjustedPriceUSD);
expect(economic!.adjustedPriceUSD).toBeLessThan(rapid!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 0.85 = 850 USD
expect(economic!.adjustedPriceUSD).toBe(850);
expect(economic!.priceAdjustmentPercent).toBe(-15);
}); });
it('RAPID has the highest price multiplier (1.2)', () => { it('RAPID doit être le plus cher', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.priceMultiplier).toBe(1.2); const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
expect(rapid.priceAdjustmentPercent).toBe(20); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID doit avoir le prix le plus élevé
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(standard!.adjustedPriceUSD);
expect(rapid!.adjustedPriceUSD).toBeGreaterThan(economic!.adjustedPriceUSD);
// Vérifier le prix attendu: 1000 * 1.20 = 1200 USD
expect(rapid!.adjustedPriceUSD).toBe(1200);
expect(rapid!.priceAdjustmentPercent).toBe(20);
}); });
it('STANDARD has no price adjustment (multiplier = 1.0)', () => { it("STANDARD doit avoir le prix de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
expect(standard.priceMultiplier).toBe(1.0); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
expect(standard.priceAdjustmentPercent).toBe(0);
// STANDARD doit avoir le prix de base (pas de changement)
expect(standard!.adjustedPriceUSD).toBe(1000);
expect(standard!.adjustedPriceEUR).toBe(900);
expect(standard!.priceAdjustmentPercent).toBe(0);
}); });
it('RAPID has the shortest transit time', () => { it('RAPID doit être le plus rapide (moins de jours de transit)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(standard.adjustedTransitDays); const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
// 20 * 0.70 = 14 const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
expect(rapid.adjustedTransitDays).toBe(14);
// RAPID doit avoir le transit time le plus court
expect(rapid!.adjustedTransitDays).toBeLessThan(standard!.adjustedTransitDays);
expect(rapid!.adjustedTransitDays).toBeLessThan(economic!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 0.70 = 14 jours
expect(rapid!.adjustedTransitDays).toBe(14);
expect(rapid!.transitAdjustmentPercent).toBe(-30);
}); });
it('ECONOMIC has the longest transit time', () => { it('ECONOMIC doit être le plus lent (plus de jours de transit)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
expect(economic.adjustedTransitDays).toBeGreaterThan(standard.adjustedTransitDays); const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// 20 * 1.50 = 30 const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
expect(economic.adjustedTransitDays).toBe(30); const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// ECONOMIC doit avoir le transit time le plus long
expect(economic!.adjustedTransitDays).toBeGreaterThan(standard!.adjustedTransitDays);
expect(economic!.adjustedTransitDays).toBeGreaterThan(rapid!.adjustedTransitDays);
// Vérifier le transit attendu: 20 * 1.50 = 30 jours
expect(economic!.adjustedTransitDays).toBe(30);
expect(economic!.transitAdjustmentPercent).toBe(50);
}); });
it('STANDARD has no transit adjustment', () => { it("STANDARD doit avoir le transit time de base (pas d'ajustement)", () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
expect(standard.adjustedTransitDays).toBe(20); const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD);
expect(standard.transitAdjustmentPercent).toBe(0);
// STANDARD doit avoir le transit time de base
expect(standard!.adjustedTransitDays).toBe(20);
expect(standard!.transitAdjustmentPercent).toBe(0);
}); });
it('offers are sorted by priceMultiplier (ECONOMIC → STANDARD → RAPID)', () => { it('les offres doivent être triées par prix croissant (ECONOMIC -> STANDARD -> RAPID)', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC); expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD); expect(offers[1].serviceLevel).toBe(ServiceLevel.STANDARD);
expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID); expect(offers[2].serviceLevel).toBe(ServiceLevel.RAPID);
// Vérifier que les prix sont dans l'ordre croissant
expect(offers[0].adjustedPriceUSD).toBeLessThan(offers[1].adjustedPriceUSD);
expect(offers[1].adjustedPriceUSD).toBeLessThan(offers[2].adjustedPriceUSD);
}); });
it('clamps transit time to minimum (5 days)', () => { it('doit conserver les informations originales du tarif', () => {
const shortTransitRate = { ...mockRate, transitDays: 3 } as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBe(5);
});
it('clamps transit time to maximum (90 days)', () => {
const longTransitRate = { ...mockRate, transitDays: 80 } as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
expect(economic.adjustedTransitDays).toBe(90);
});
it('preserves the original rate reference', () => {
const offers = service.generateOffers(mockRate); const offers = service.generateOffers(mockRate);
for (const offer of offers) { for (const offer of offers) {
expect(offer.rate).toBe(mockRate); expect(offer.rate).toBe(mockRate);
expect(offer.originalPriceUSD).toBe(1000);
expect(offer.originalPriceEUR).toBe(900);
expect(offer.originalTransitDays).toBe(20); expect(offer.originalTransitDays).toBe(20);
} }
}); });
it('doit appliquer la contrainte de transit time minimum (5 jours)', () => {
// Tarif avec transit time très court (3 jours)
const shortTransitRate = {
...mockRate,
transitDays: 3,
} as any;
const offers = service.generateOffers(shortTransitRate);
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID);
// RAPID avec 3 * 0.70 = 2.1 jours, mais minimum est 5 jours
expect(rapid!.adjustedTransitDays).toBe(5);
});
it('doit appliquer la contrainte de transit time maximum (90 jours)', () => {
// Tarif avec transit time très long (80 jours)
const longTransitRate = {
...mockRate,
transitDays: 80,
} as any;
const offers = service.generateOffers(longTransitRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC);
// ECONOMIC avec 80 * 1.50 = 120 jours, mais maximum est 90 jours
expect(economic!.adjustedTransitDays).toBe(90);
});
}); });
describe('generateOffersForRates', () => { describe('generateOffersForRates', () => {
it('generates 3 offers per rate', () => { it('doit générer 3 offres par tarif', () => {
const rate2 = { ...mockRate, companyName: 'Another Carrier' } as any; const rate1 = mockRate;
const offers = service.generateOffersForRates([mockRate, rate2]); const rate2 = {
expect(offers).toHaveLength(6); ...mockRate,
companyName: 'Another Carrier',
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
expect(offers).toHaveLength(6); // 2 tarifs * 3 offres
});
it('doit trier toutes les offres par prix croissant', () => {
const rate1 = mockRate; // Prix base: 1000 USD
const rate2 = {
...mockRate,
companyName: 'Cheaper Carrier',
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'), // Prix base plus bas
},
} as any;
const offers = service.generateOffersForRates([rate1, rate2]);
// Vérifier que les prix sont triés
for (let i = 0; i < offers.length - 1; i++) {
expect(offers[i].adjustedPriceUSD).toBeLessThanOrEqual(offers[i + 1].adjustedPriceUSD);
}
// L'offre la moins chère devrait être ECONOMIC du rate2
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
expect(offers[0].rate.companyName).toBe('Cheaper Carrier');
}); });
}); });
describe('generateOffersForServiceLevel', () => { describe('generateOffersForServiceLevel', () => {
it('generates only RAPID offers', () => { it('doit générer uniquement les offres RAPID', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID); const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.RAPID);
expect(offers).toHaveLength(1); expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID); expect(offers[0].serviceLevel).toBe(ServiceLevel.RAPID);
}); });
it('generates only ECONOMIC offers', () => { it('doit générer uniquement les offres ECONOMIC', () => {
const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC); const offers = service.generateOffersForServiceLevel([mockRate], ServiceLevel.ECONOMIC);
expect(offers).toHaveLength(1); expect(offers).toHaveLength(1);
expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC); expect(offers[0].serviceLevel).toBe(ServiceLevel.ECONOMIC);
}); });
}); });
describe('getCheapestOffer', () => {
it("doit retourner l'offre ECONOMIC la moins chère", () => {
const rate1 = mockRate; // 1000 USD base
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(500, 'USD'),
},
} as any;
const cheapest = service.getCheapestOffer([rate1, rate2]);
expect(cheapest).not.toBeNull();
expect(cheapest!.serviceLevel).toBe(ServiceLevel.ECONOMIC);
// 500 * 0.85 = 425 USD
expect(cheapest!.adjustedPriceUSD).toBe(425);
});
it('doit retourner null si aucun tarif', () => {
const cheapest = service.getCheapestOffer([]);
expect(cheapest).toBeNull();
});
});
describe('getFastestOffer', () => {
it("doit retourner l'offre RAPID la plus rapide", () => {
const rate1 = { ...mockRate, transitDays: 20 } as any;
const rate2 = { ...mockRate, transitDays: 10 } as any;
const fastest = service.getFastestOffer([rate1, rate2]);
expect(fastest).not.toBeNull();
expect(fastest!.serviceLevel).toBe(ServiceLevel.RAPID);
// 10 * 0.70 = 7 jours
expect(fastest!.adjustedTransitDays).toBe(7);
});
it('doit retourner null si aucun tarif', () => {
const fastest = service.getFastestOffer([]);
expect(fastest).toBeNull();
});
});
describe('getBestOffersPerServiceLevel', () => { describe('getBestOffersPerServiceLevel', () => {
it('returns one offer per service level', () => { it('doit retourner la meilleure offre de chaque niveau de service', () => {
const best = service.getBestOffersPerServiceLevel([mockRate]); const rate1 = mockRate;
const rate2 = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(800, 'USD'),
},
} as any;
const best = service.getBestOffersPerServiceLevel([rate1, rate2]);
expect(best.rapid).not.toBeNull(); expect(best.rapid).not.toBeNull();
expect(best.standard).not.toBeNull(); expect(best.standard).not.toBeNull();
expect(best.economic).not.toBeNull(); expect(best.economic).not.toBeNull();
});
it('returns null for all levels when no rates', () => { // Toutes doivent provenir du rate2 (moins cher)
const best = service.getBestOffersPerServiceLevel([]); expect(best.rapid!.originalPriceUSD).toBe(800);
expect(best.rapid).toBeNull(); expect(best.standard!.originalPriceUSD).toBe(800);
expect(best.standard).toBeNull(); expect(best.economic!.originalPriceUSD).toBe(800);
expect(best.economic).toBeNull();
}); });
}); });
describe('isRateEligible', () => { describe('isRateEligible', () => {
it('accepts a valid rate', () => { it('doit accepter un tarif valide', () => {
expect(service.isRateEligible(mockRate)).toBe(true); expect(service.isRateEligible(mockRate)).toBe(true);
}); });
it('rejects a rate with transitDays = 0', () => { it('doit rejeter un tarif avec transit time = 0', () => {
const invalid = { ...mockRate, transitDays: 0 } as any; const invalidRate = { ...mockRate, transitDays: 0 } as any;
expect(service.isRateEligible(invalid)).toBe(false); expect(service.isRateEligible(invalidRate)).toBe(false);
}); });
it('rejects a rate with freightRatePerCBM = 0 and freightMinimum = 0', () => { it('doit rejeter un tarif avec prix = 0', () => {
const invalid = { const invalidRate = {
...mockRate, ...mockRate,
freight: { ...mockRate.freight, freightRatePerCBM: 0, freightMinimum: 0 }, pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any; } as any;
expect(service.isRateEligible(invalid)).toBe(false); expect(service.isRateEligible(invalidRate)).toBe(false);
}); });
it('rejects an expired rate', () => { it('doit rejeter un tarif expiré', () => {
const expired = { ...mockRate, isValidForDate: () => false } as any; const expiredRate = {
expect(service.isRateEligible(expired)).toBe(false); ...mockRate,
isValidForDate: () => false,
} as any;
expect(service.isRateEligible(expiredRate)).toBe(false);
}); });
}); });
describe('Business logic invariants', () => { describe('filterEligibleRates', () => {
it('RAPID priceMultiplier always > ECONOMIC priceMultiplier', () => { it('doit filtrer les tarifs invalides', () => {
const offers = service.generateOffers(mockRate); const validRate = mockRate;
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; const invalidRate1 = { ...mockRate, transitDays: 0 } as any;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; const invalidRate2 = {
expect(rapid.priceMultiplier).toBeGreaterThan(economic.priceMultiplier); ...mockRate,
}); pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(0, 'USD'),
},
} as any;
const eligibleRates = service.filterEligibleRates([validRate, invalidRate1, invalidRate2]);
expect(eligibleRates).toHaveLength(1);
expect(eligibleRates[0]).toBe(validRate);
});
});
describe('Validation de la logique métier', () => {
it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
// Test avec différents prix de base
const prices = [100, 500, 1000, 5000, 10000];
for (const price of prices) {
const rate = {
...mockRate,
pricing: {
...mockRate.pricing,
basePriceUSD: Money.create(price, 'USD'),
},
} as any;
it('RAPID transit always < ECONOMIC transit for different base days', () => {
for (const days of [5, 10, 20, 30, 60]) {
const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate); const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!; const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!; const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
}
});
it('RAPID doit TOUJOURS être plus rapide que ECONOMIC', () => {
// Test avec différents transit times de base
const transitDays = [5, 10, 20, 30, 60];
for (const days of transitDays) {
const rate = { ...mockRate, transitDays: days } as any;
const offers = service.generateOffers(rate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays); expect(rapid.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
} }
}); });
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le prix', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedPriceUSD).toBeGreaterThan(economic.adjustedPriceUSD);
expect(standard.adjustedPriceUSD).toBeLessThan(rapid.adjustedPriceUSD);
});
it('STANDARD doit TOUJOURS être entre ECONOMIC et RAPID pour le transit time', () => {
const offers = service.generateOffers(mockRate);
const economic = offers.find(o => o.serviceLevel === ServiceLevel.ECONOMIC)!;
const standard = offers.find(o => o.serviceLevel === ServiceLevel.STANDARD)!;
const rapid = offers.find(o => o.serviceLevel === ServiceLevel.RAPID)!;
expect(standard.adjustedTransitDays).toBeLessThan(economic.adjustedTransitDays);
expect(standard.adjustedTransitDays).toBeGreaterThan(rapid.adjustedTransitDays);
});
}); });
}); });

View File

@ -3,9 +3,9 @@ import { CsvRate } from '../entities/csv-rate.entity';
/** /**
* Service Level Types * Service Level Types
* *
* - RAPID : +20% price, -30% transit (express, priority) * - RAPID: Offre la plus chère + la plus rapide (transit time réduit)
* - STANDARD : base price and transit * - STANDARD: Offre standard (prix et transit time de base)
* - ECONOMIC : -15% price, +50% transit (cheapest, slowest) * - ECONOMIC: Offre la moins chère + la plus lente (transit time augmenté)
*/ */
export enum ServiceLevel { export enum ServiceLevel {
RAPID = 'RAPID', RAPID = 'RAPID',
@ -13,110 +13,243 @@ export enum ServiceLevel {
ECONOMIC = 'ECONOMIC', ECONOMIC = 'ECONOMIC',
} }
/**
* Rate Offer - Variante d'un tarif avec un niveau de service
*/
export interface RateOffer { export interface RateOffer {
rate: CsvRate; rate: CsvRate;
serviceLevel: ServiceLevel; serviceLevel: ServiceLevel;
priceMultiplier: number; adjustedPriceUSD: number;
adjustedPriceEUR: number;
adjustedTransitDays: number; adjustedTransitDays: number;
originalPriceUSD: number;
originalPriceEUR: number;
originalTransitDays: number; originalTransitDays: number;
priceAdjustmentPercent: number; priceAdjustmentPercent: number;
transitAdjustmentPercent: number; transitAdjustmentPercent: number;
description: string; description: string;
} }
/**
* Configuration pour les ajustements de prix et transit par niveau de service
*/
interface ServiceLevelConfig { interface ServiceLevelConfig {
priceMultiplier: number; priceMultiplier: number; // Multiplicateur de prix (1.0 = pas de changement)
transitMultiplier: number; transitMultiplier: number; // Multiplicateur de transit time (1.0 = pas de changement)
description: string; description: string;
} }
/** /**
* Generates RAPID / STANDARD / ECONOMIC variants for a given CSV rate. * Rate Offer Generator Service
* *
* Price adjustment is applied to the total calculated price in the search service * Service du domaine qui génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV.
* this service only stores the multiplier and the adjusted transit time. *
* Règles métier:
* - RAPID : Prix +20%, Transit -30% (plus cher, plus rapide)
* - STANDARD : Prix +0%, Transit +0% (tarif de base)
* - ECONOMIC : Prix -15%, Transit +50% (moins cher, plus lent)
*
* Pure domain logic - Pas de dépendances framework
*/ */
export class RateOfferGeneratorService { export class RateOfferGeneratorService {
/**
* Configuration par défaut des niveaux de service
* Ces valeurs peuvent être ajustées selon les besoins métier
*/
private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = { private readonly SERVICE_LEVEL_CONFIGS: Record<ServiceLevel, ServiceLevelConfig> = {
[ServiceLevel.RAPID]: { [ServiceLevel.RAPID]: {
priceMultiplier: 1.2, priceMultiplier: 1.2, // +20% du prix de base
transitMultiplier: 0.7, transitMultiplier: 0.7, // -30% du temps de transit (plus rapide)
description: 'Express Livraison rapide avec service prioritaire', description: 'Express - Livraison rapide avec service prioritaire',
}, },
[ServiceLevel.STANDARD]: { [ServiceLevel.STANDARD]: {
priceMultiplier: 1.0, priceMultiplier: 1.0, // Prix de base (pas de changement)
transitMultiplier: 1.0, transitMultiplier: 1.0, // Transit time de base (pas de changement)
description: 'Standard Service régulier au meilleur rapport qualité/prix', description: 'Standard - Service régulier au meilleur rapport qualité/prix',
}, },
[ServiceLevel.ECONOMIC]: { [ServiceLevel.ECONOMIC]: {
priceMultiplier: 0.85, priceMultiplier: 0.85, // -15% du prix de base
transitMultiplier: 1.5, transitMultiplier: 1.5, // +50% du temps de transit (plus lent)
description: 'Économique Tarif réduit avec délai étendu', description: 'Économique - Tarif réduit avec délai étendu',
}, },
}; };
/**
* Transit time minimum (en jours) pour garantir la cohérence
* Même avec réduction, on ne peut pas descendre en dessous de ce minimum
*/
private readonly MIN_TRANSIT_DAYS = 5; private readonly MIN_TRANSIT_DAYS = 5;
/**
* Transit time maximum (en jours) pour garantir la cohérence
* Même avec augmentation, on ne peut pas dépasser ce maximum
*/
private readonly MAX_TRANSIT_DAYS = 90; private readonly MAX_TRANSIT_DAYS = 90;
/**
* Génère 3 offres (RAPID, STANDARD, ECONOMIC) à partir d'un tarif CSV
*
* @param rate - Le tarif CSV de base
* @returns Tableau de 3 offres triées par prix croissant (ECONOMIC, STANDARD, RAPID)
*/
generateOffers(rate: CsvRate): RateOffer[] { generateOffers(rate: CsvRate): RateOffer[] {
const offers: RateOffer[] = []; const offers: RateOffer[] = [];
// Extraire les prix de base
const basePriceUSD = rate.pricing.basePriceUSD.getAmount();
const basePriceEUR = rate.pricing.basePriceEUR.getAmount();
const baseTransitDays = rate.transitDays;
// Générer les 3 offres
for (const serviceLevel of Object.values(ServiceLevel)) { for (const serviceLevel of Object.values(ServiceLevel)) {
const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel]; const config = this.SERVICE_LEVEL_CONFIGS[serviceLevel];
const rawTransit = rate.transitDays * config.transitMultiplier;
const adjustedTransitDays = this.clampTransit(Math.round(rawTransit)); // Calculer les prix ajustés
const adjustedPriceUSD = this.roundPrice(basePriceUSD * config.priceMultiplier);
const adjustedPriceEUR = this.roundPrice(basePriceEUR * config.priceMultiplier);
// Calculer le transit time ajusté (avec contraintes min/max)
const rawTransitDays = baseTransitDays * config.transitMultiplier;
const adjustedTransitDays = this.constrainTransitDays(Math.round(rawTransitDays));
// Calculer les pourcentages d'ajustement
const priceAdjustmentPercent = Math.round((config.priceMultiplier - 1) * 100);
const transitAdjustmentPercent = Math.round((config.transitMultiplier - 1) * 100);
offers.push({ offers.push({
rate, rate,
serviceLevel, serviceLevel,
priceMultiplier: config.priceMultiplier, adjustedPriceUSD,
adjustedPriceEUR,
adjustedTransitDays, adjustedTransitDays,
originalTransitDays: rate.transitDays, originalPriceUSD: basePriceUSD,
priceAdjustmentPercent: Math.round((config.priceMultiplier - 1) * 100), originalPriceEUR: basePriceEUR,
transitAdjustmentPercent: Math.round((config.transitMultiplier - 1) * 100), originalTransitDays: baseTransitDays,
priceAdjustmentPercent,
transitAdjustmentPercent,
description: config.description, description: config.description,
}); });
} }
// ECONOMIC → STANDARD → RAPID (cheapest first) // Trier par prix croissant: ECONOMIC (moins cher) -> STANDARD -> RAPID (plus cher)
return offers.sort((a, b) => a.priceMultiplier - b.priceMultiplier); return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
} }
/**
* Génère plusieurs offres pour une liste de tarifs
*
* @param rates - Liste de tarifs CSV
* @returns Liste de toutes les offres générées (3 par tarif), triées par prix
*/
generateOffersForRates(rates: CsvRate[]): RateOffer[] { generateOffersForRates(rates: CsvRate[]): RateOffer[] {
return rates.flatMap(rate => this.generateOffers(rate)); const allOffers: RateOffer[] = [];
for (const rate of rates) {
const offers = this.generateOffers(rate);
allOffers.push(...offers);
}
// Trier toutes les offres par prix croissant
return allOffers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
} }
/**
* Génère uniquement les offres d'un niveau de service spécifique
*
* @param rates - Liste de tarifs CSV
* @param serviceLevel - Niveau de service souhaité (RAPID, STANDARD, ECONOMIC)
* @returns Liste des offres du niveau de service demandé, triées par prix
*/
generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] { generateOffersForServiceLevel(rates: CsvRate[], serviceLevel: ServiceLevel): RateOffer[] {
return rates const offers: RateOffer[] = [];
.map(rate => this.generateOffers(rate).find(o => o.serviceLevel === serviceLevel)!)
.filter(Boolean); for (const rate of rates) {
const allOffers = this.generateOffers(rate);
const matchingOffer = allOffers.find(o => o.serviceLevel === serviceLevel);
if (matchingOffer) {
offers.push(matchingOffer);
}
}
// Trier par prix croissant
return offers.sort((a, b) => a.adjustedPriceUSD - b.adjustedPriceUSD);
} }
/**
* Obtient l'offre la moins chère (ECONOMIC) parmi une liste de tarifs
*/
getCheapestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const economicOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC);
return economicOffers[0] || null;
}
/**
* Obtient l'offre la plus rapide (RAPID) parmi une liste de tarifs
*/
getFastestOffer(rates: CsvRate[]): RateOffer | null {
if (rates.length === 0) return null;
const rapidOffers = this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID);
// Trier par transit time croissant (plus rapide en premier)
rapidOffers.sort((a, b) => a.adjustedTransitDays - b.adjustedTransitDays);
return rapidOffers[0] || null;
}
/**
* Obtient les meilleures offres (meilleur rapport qualité/prix)
* Retourne une offre de chaque niveau de service avec le meilleur prix
*/
getBestOffersPerServiceLevel(rates: CsvRate[]): { getBestOffersPerServiceLevel(rates: CsvRate[]): {
rapid: RateOffer | null; rapid: RateOffer | null;
standard: RateOffer | null; standard: RateOffer | null;
economic: RateOffer | null; economic: RateOffer | null;
} { } {
return { return {
rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] ?? null, rapid: this.generateOffersForServiceLevel(rates, ServiceLevel.RAPID)[0] || null,
standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] ?? null, standard: this.generateOffersForServiceLevel(rates, ServiceLevel.STANDARD)[0] || null,
economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] ?? null, economic: this.generateOffersForServiceLevel(rates, ServiceLevel.ECONOMIC)[0] || null,
}; };
} }
/**
* Arrondit le prix à 2 décimales
*/
private roundPrice(price: number): number {
return Math.round(price * 100) / 100;
}
/**
* Contraint le transit time entre les limites min et max
*/
private constrainTransitDays(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
/**
* Vérifie si un tarif est éligible pour la génération d'offres
*
* Critères:
* - Transit time doit être > 0
* - Prix doit être > 0
* - Tarif doit être valide (non expiré)
*/
isRateEligible(rate: CsvRate): boolean { isRateEligible(rate: CsvRate): boolean {
if (rate.transitDays <= 0) return false; if (rate.transitDays <= 0) return false;
// A rate is usable if it has a freight rate or at least a freight minimum if (rate.pricing.basePriceUSD.getAmount() <= 0) return false;
if (rate.freight.freightRatePerCBM <= 0 && rate.freight.freightMinimum <= 0) return false;
if (!rate.isValidForDate(new Date())) return false; if (!rate.isValidForDate(new Date())) return false;
return true; return true;
} }
/**
* Filtre les tarifs éligibles pour la génération d'offres
*/
filterEligibleRates(rates: CsvRate[]): CsvRate[] { filterEligibleRates(rates: CsvRate[]): CsvRate[] {
return rates.filter(rate => this.isRateEligible(rate)); return rates.filter(rate => this.isRateEligible(rate));
} }
private clampTransit(days: number): number {
return Math.max(this.MIN_TRANSIT_DAYS, Math.min(this.MAX_TRANSIT_DAYS, days));
}
} }

View File

@ -14,4 +14,3 @@ export * from './booking-status.vo';
export * from './subscription-plan.vo'; export * from './subscription-plan.vo';
export * from './subscription-status.vo'; export * from './subscription-status.vo';
export * from './license-status.vo'; export * from './license-status.vo';
export * from './locale.vo';

View File

@ -1,19 +0,0 @@
/**
* 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;
}

View File

@ -1,9 +0,0 @@
{
"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"
}

View File

@ -1,10 +0,0 @@
{
"status": {
"DRAFT": "Draft",
"CONFIRMED": "Confirmed",
"SHIPPED": "Shipped",
"DELIVERED": "Delivered",
"CANCELLED": "Cancelled",
"REJECTED": "Rejected"
}
}

View File

@ -1,5 +0,0 @@
{
"SUCCESS": "Success",
"YES": "Yes",
"NO": "No"
}

View File

@ -1,44 +0,0 @@
{
"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"
}
}

View File

@ -1,23 +0,0 @@
{
"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"
}

View File

@ -1,30 +0,0 @@
{
"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."
}
}
}

View File

@ -1,36 +0,0 @@
{
"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}"
}
}

View File

@ -1,30 +0,0 @@
{
"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'"
}

View File

@ -1,9 +0,0 @@
{
"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é"
}

View File

@ -1,10 +0,0 @@
{
"status": {
"DRAFT": "Brouillon",
"CONFIRMED": "Confirmée",
"SHIPPED": "Expédiée",
"DELIVERED": "Livrée",
"CANCELLED": "Annulée",
"REJECTED": "Refusée"
}
}

View File

@ -1,5 +0,0 @@
{
"SUCCESS": "Succès",
"YES": "Oui",
"NO": "Non"
}

View File

@ -1,44 +0,0 @@
{
"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"
}
}

View File

@ -1,23 +0,0 @@
{
"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"
}

View File

@ -1,30 +0,0 @@
{
"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."
}
}
}

View File

@ -1,36 +0,0 @@
{
"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}"
}
}

View File

@ -1,30 +0,0 @@
{
"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'"
}

View File

@ -5,58 +5,41 @@ import * as path from 'path';
/** /**
* CSV Converter Service * CSV Converter Service
* *
* Detects and converts CSV files to the standard 33-column Xpeditis format. * Détecte automatiquement le format du CSV et convertit au format attendu
* * Supporte:
* Standard format columns (33): * - Format standard Xpeditis
* companyName, companyEmail, originCFS, originCode, portOfLoading, routing, * - Format "Frais FOB FRET"
* destinationCFS, destinationCode, destinationCountry, containerType,
* freightCurrency, freightRatePerCBM, freightMinimum,
* fobCurrency, fobDocumentation, fobISPS, fobHandling, fobHandlingUnit,
* fobHandlingMinimum, fobSolas, fobCustoms, fobAMS_ACI, fobISF5, fobDGAdmin,
* dgSurchargeCurrency, dgSurchargeRate, dgSurchargeUnit, dgSurchargeMin,
* remarks, frequency, transitDays, validFrom, validUntil
*/ */
@Injectable() @Injectable()
export class CsvConverterService { export class CsvConverterService {
private readonly logger = new Logger(CsvConverterService.name); private readonly logger = new Logger(CsvConverterService.name);
// Headers du format standard attendu
private readonly STANDARD_HEADERS = [ private readonly STANDARD_HEADERS = [
'companyName', 'companyName',
'companyEmail', 'origin',
'originCFS', 'destination',
'originCode',
'portOfLoading',
'routing',
'destinationCFS',
'destinationCode',
'destinationCountry',
'containerType', 'containerType',
'freightCurrency', 'minVolumeCBM',
'freightRatePerCBM', 'maxVolumeCBM',
'freightMinimum', 'minWeightKG',
'fobCurrency', 'maxWeightKG',
'fobDocumentation', 'palletCount',
'fobISPS', 'pricePerCBM',
'fobHandling', 'pricePerKG',
'fobHandlingUnit', 'basePriceUSD',
'fobHandlingMinimum', 'basePriceEUR',
'fobSolas', 'currency',
'fobCustoms', 'hasSurcharges',
'fobAMS_ACI', 'surchargeBAF',
'fobISF5', 'surchargeCAF',
'fobDGAdmin', 'surchargeDetails',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays', 'transitDays',
'validFrom', 'validFrom',
'validUntil', 'validUntil',
]; ];
// Legacy "Frais FOB FRET" format indicators (older Excel exports) // Headers du format "Frais FOB FRET"
private readonly FOB_FRET_HEADERS = [ private readonly FOB_FRET_HEADERS = [
'Origine UN code', 'Origine UN code',
'Destination UN code', 'Destination UN code',
@ -66,32 +49,259 @@ export class CsvConverterService {
'Transit time', 'Transit time',
]; ];
/**
* Parse une ligne CSV en gérant les champs entre guillemets
*/
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
/**
* Détecte le format du CSV
*/
async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> { async detectFormat(filePath: string): Promise<'STANDARD' | 'FOB_FRET' | 'UNKNOWN'> {
try { try {
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim()); const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) return 'UNKNOWN';
if (lines.length === 0) {
return 'UNKNOWN';
}
// Vérifier les 2 premières lignes (parfois la vraie ligne d'en-tête est la ligne 2)
for (let i = 0; i < Math.min(2, lines.length); i++) { for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]); const headers = this.parseCSVLine(lines[i]);
if (this.STANDARD_HEADERS.some(h => headers.includes(h))) return 'STANDARD';
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) return 'FOB_FRET'; // Vérifier format standard
const hasStandardHeaders = this.STANDARD_HEADERS.some(h => headers.includes(h));
if (hasStandardHeaders) {
return 'STANDARD';
}
// Vérifier format FOB FRET
const hasFobFretHeaders = this.FOB_FRET_HEADERS.some(h => headers.includes(h));
if (hasFobFretHeaders) {
return 'FOB_FRET';
}
} }
return 'UNKNOWN'; return 'UNKNOWN';
} catch { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Error detecting CSV format: ${errorMessage}`);
return 'UNKNOWN'; return 'UNKNOWN';
} }
} }
/**
* Calcule les surcharges à partir des colonnes FOB
*/
private calculateSurcharges(row: Record<string, string>): string {
const surcharges: string[] = [];
const surchargeFields = [
{ key: 'Documentation (LS et Minimum)', prefix: 'DOC' },
{ key: 'ISPS (LS et Minimum)', prefix: 'ISPS' },
{ key: 'Manutention', prefix: 'HANDLING' },
{ key: 'Solas (LS et Minimum)', prefix: 'SOLAS' },
{ key: 'Douane (LS et Minimum)', prefix: 'CUSTOMS' },
{ key: 'AMS/ACI (LS et Minimum)', prefix: 'AMS_ACI' },
{ key: 'ISF5 (LS et Minimum)', prefix: 'ISF5' },
{ key: 'Frais admin de dangereux (LS et Minimum)', prefix: 'DG_FEE' },
];
surchargeFields.forEach(({ key, prefix }) => {
if (row[key]) {
const unit = key === 'Manutention' ? row['Unité de manutention (UP;Tonne)'] || 'UP' : '';
surcharges.push(`${prefix}:${row[key]}${unit ? ' ' + unit : ''}`);
}
});
return surcharges.join(' | ');
}
/**
* Convertit une ligne FOB FRET vers le format standard
*/
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const currency = row['Devise FRET'] || 'USD';
const freightRate = parseFloat(row['Taux de FRET (UP)']) || 0;
const minFreight = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time']) || 0;
// Calcul des surcharges
const surchargeDetails = this.calculateSurcharges(row);
const hasSurcharges = surchargeDetails.length > 0;
// Dates de validité (90 jours par défaut)
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
// Volumes et poids standards pour LCL
const minVolumeCBM = 1;
const maxVolumeCBM = 20;
const minWeightKG = 100;
const maxWeightKG = 20000;
// Prix par CBM
const pricePerCBM = freightRate > 0 ? freightRate : minFreight;
// Prix par KG (estimation: prix CBM / 200 kg/m³)
const pricePerKG = pricePerCBM > 0 ? (pricePerCBM / 200).toFixed(2) : '0';
return {
companyName,
origin: row['Origine UN code'] || '',
destination: row['Destination UN code'] || '',
containerType: 'LCL',
minVolumeCBM,
maxVolumeCBM,
minWeightKG,
maxWeightKG,
palletCount: 0,
pricePerCBM,
pricePerKG,
basePriceUSD: currency === 'USD' ? pricePerCBM : 0,
basePriceEUR: currency === 'EUR' ? pricePerCBM : 0,
currency,
hasSurcharges,
surchargeBAF: '',
surchargeCAF: '',
surchargeDetails,
transitDays,
validFrom,
validUntil,
};
}
/**
* Convertit un CSV FOB FRET vers le format standard
*/
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting FOB FRET CSV: ${inputPath}`);
try {
// Lire le fichier
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length < 2) {
throw new Error('CSV file is empty or has no data rows');
}
// Trouver la ligne d'en-tête réelle (chercher celle avec "Devise FRET")
let headerLineIndex = 0;
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
headerLineIndex = i;
break;
}
}
// Parse headers
const headers = this.parseCSVLine(lines[headerLineIndex]);
this.logger.log(`Found FOB FRET headers at line ${headerLineIndex + 1}`);
// Parse data rows (commencer après la ligne d'en-tête)
const dataRows: Record<string, string>[] = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i]);
const row: Record<string, string> = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
// Vérifier que la ligne a des données valides
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
this.logger.log(`Found ${dataRows.length} valid data rows`);
// Convertir les lignes
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
// Générer le CSV de sortie
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header];
// Échapper les virgules et quotes
if (
typeof value === 'string' &&
(value.includes(',') || value.includes('"') || value.includes('\n'))
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
// Écrire le fichier converti (garder le chemin absolu)
const outputPath = inputPath.replace('.csv', '-converted.csv');
const absoluteOutputPath = path.isAbsolute(outputPath)
? outputPath
: path.resolve(process.cwd(), outputPath);
await fs.writeFile(absoluteOutputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion completed: ${absoluteOutputPath} (${convertedRows.length} rows)`);
return {
outputPath: absoluteOutputPath,
rowsConverted: convertedRows.length,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Error converting CSV: ${errorMessage}`, errorStack);
throw new Error(`CSV conversion failed: ${errorMessage}`);
}
}
/**
* Convertit automatiquement un CSV si nécessaire
*/
async autoConvert( async autoConvert(
inputPath: string, inputPath: string,
companyName: string companyName: string
): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> { ): Promise<{ convertedPath: string; wasConverted: boolean; rowsConverted?: number }> {
const format = await this.detectFormat(inputPath); const format = await this.detectFormat(inputPath);
this.logger.log(`Detected CSV format: ${format} for ${inputPath}`);
this.logger.log(`Detected CSV format: ${format}`);
if (format === 'STANDARD') { if (format === 'STANDARD') {
return { convertedPath: inputPath, wasConverted: false }; return {
convertedPath: inputPath,
wasConverted: false,
};
} }
if (format === 'FOB_FRET') { if (format === 'FOB_FRET') {
@ -103,134 +313,6 @@ export class CsvConverterService {
}; };
} }
throw new Error( throw new Error(`Unknown CSV format. Please provide a valid CSV file.`);
'Unknown CSV format. Please provide a file matching the standard 33-column schema.'
);
}
async convertFobFretToStandard(
inputPath: string,
companyName: string
): Promise<{ outputPath: string; rowsConverted: number }> {
this.logger.log(`Converting legacy FOB FRET CSV: ${inputPath}`);
const content = await fs.readFile(inputPath, 'utf-8');
const lines = content.split('\n').filter(l => l.trim());
if (lines.length < 2) throw new Error('CSV file is empty or has no data rows');
// Find the header line
let headerLineIndex = 0;
for (let i = 0; i < Math.min(2, lines.length); i++) {
const headers = this.parseCSVLine(lines[i]);
if (this.FOB_FRET_HEADERS.some(h => headers.includes(h))) {
headerLineIndex = i;
break;
}
}
const headers = this.parseCSVLine(lines[headerLineIndex]);
const dataRows: Record<string, string>[] = [];
for (let i = headerLineIndex + 1; i < lines.length; i++) {
const values = this.parseCSVLine(lines[i]);
const row: Record<string, string> = {};
headers.forEach((header, idx) => (row[header] = values[idx] || ''));
if (row['Origine UN code'] && row['Destination UN code']) {
dataRows.push(row);
}
}
const convertedRows = dataRows.map(row => this.convertFobFretRow(row, companyName));
const outputLines: string[] = [this.STANDARD_HEADERS.join(',')];
convertedRows.forEach(row => {
const values = this.STANDARD_HEADERS.map(header => {
const value = row[header] ?? '';
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
});
outputLines.push(values.join(','));
});
const outputPath = path.isAbsolute(inputPath)
? inputPath.replace('.csv', '-converted.csv')
: path.resolve(process.cwd(), inputPath.replace('.csv', '-converted.csv'));
await fs.writeFile(outputPath, outputLines.join('\n'), 'utf-8');
this.logger.log(`Conversion complete: ${outputPath} (${convertedRows.length} rows)`);
return { outputPath, rowsConverted: convertedRows.length };
}
private convertFobFretRow(row: Record<string, string>, companyName: string): Record<string, any> {
const freightCurrency = row['Devise FRET'] || 'USD';
const freightRatePerCBM = parseFloat(row['Taux de FRET (UP)']) || 0;
const freightMinimum = parseFloat(row['Minimum FRET (LS)']) || 0;
const transitDays = parseInt(row['Transit time'], 10) || 0;
const fobCurrency = row['Devise FOB'] || 'EUR';
const validFrom = new Date().toISOString().split('T')[0];
const validUntil = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const originCode = row['Origine UN code'] || '';
const destinationCode = row['Destination UN code'] || '';
return {
companyName,
companyEmail: row['Email'] || '',
originCFS: row['Origine CFS'] || originCode,
originCode,
portOfLoading: row['Port of Loading'] || originCode,
routing: row['Routing'] || 'Direct',
destinationCFS: row['Destination CFS'] || destinationCode,
destinationCode,
destinationCountry: row['Destination Country'] || '',
containerType: 'LCL',
freightCurrency,
freightRatePerCBM,
freightMinimum,
fobCurrency,
fobDocumentation: parseInt(row['Documentation (LS et Minimum)'], 10) || 0,
fobISPS: parseInt(row['ISPS (LS et Minimum)'], 10) || 0,
fobHandling: parseInt(row['Manutention'], 10) || 0,
fobHandlingUnit: row['Unité de manutention (UP;Tonne)'] || 'W',
fobHandlingMinimum: parseInt(row['Minimum manutention'], 10) || 0,
fobSolas: parseInt(row['Solas (LS et Minimum)'], 10) || 0,
fobCustoms: parseInt(row['Douane (LS et Minimum)'], 10) || 0,
fobAMS_ACI: parseFloat(row['AMS/ACI (LS et Minimum)']) || 0,
fobISF5: parseFloat(row['ISF5 (LS et Minimum)']) || 0,
fobDGAdmin: parseInt(row['Frais admin de dangereux (LS et Minimum)'], 10) || 0,
dgSurchargeCurrency: row['Devise surcharge DG'] || fobCurrency,
dgSurchargeRate: row['Taux surcharge DG'] || '0',
dgSurchargeUnit: row['Unité surcharge DG'] || 'LS',
dgSurchargeMin: row['Minimum surcharge DG'] || '0',
remarks: row['Remarques'] || '',
frequency: row['Frequence'] || 'Weekly',
transitDays,
validFrom,
validUntil,
};
}
private parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
} }
} }

View File

@ -4,109 +4,61 @@ import { parse } from 'csv-parse/sync';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port'; import { CsvRateLoaderPort } from '@domain/ports/out/csv-rate-loader.port';
import { import { CsvRate } from '@domain/entities/csv-rate.entity';
CsvRate,
FreightPricing,
FobCharges,
DgSurchargeInfo,
DgSurchargeValue,
HandlingUnit,
FrequencyType,
} from '@domain/entities/csv-rate.entity';
import { PortCode } from '@domain/value-objects/port-code.vo'; import { PortCode } from '@domain/value-objects/port-code.vo';
import { ContainerType } from '@domain/value-objects/container-type.vo'; import { ContainerType } from '@domain/value-objects/container-type.vo';
import { Money } from '@domain/value-objects/money.vo';
import { Surcharge, SurchargeType, SurchargeCollection } from '@domain/value-objects/surcharge.vo';
import { DateRange } from '@domain/value-objects/date-range.vo'; import { DateRange } from '@domain/value-objects/date-range.vo';
import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter'; import { S3StorageAdapter } from '@infrastructure/storage/s3-storage.adapter';
import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository'; import { TypeOrmCsvRateConfigRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-csv-rate-config.repository';
/** /**
* Standardized 33-column CSV row. * CSV Row Interface
* All suppliers share this exact schema. * Maps to CSV file structure
*/ */
interface CsvRow { interface CsvRow {
// Supplier identity
companyName: string; companyName: string;
companyEmail: string; origin: string;
// Route geography destination: string;
originCFS: string;
originCode: string;
portOfLoading: string;
routing: string;
destinationCFS: string;
destinationCode: string;
destinationCountry: string;
// Container
containerType: string; containerType: string;
// Freight minVolumeCBM: string;
freightCurrency: string; maxVolumeCBM: string;
freightRatePerCBM: string; minWeightKG: string;
freightMinimum: string; maxWeightKG: string;
// FOB charges palletCount: string;
fobCurrency: string; pricePerCBM: string;
fobDocumentation: string; pricePerKG: string;
fobISPS: string; basePriceUSD: string;
fobHandling: string; basePriceEUR: string;
fobHandlingUnit: string; currency: string;
fobHandlingMinimum: string; hasSurcharges: string;
fobSolas: string; surchargeBAF?: string;
fobCustoms: string; surchargeCAF?: string;
fobAMS_ACI: string; surchargeDetails?: string;
fobISF5: string;
fobDGAdmin: string;
// DG surcharge
dgSurchargeCurrency: string;
dgSurchargeRate: string;
dgSurchargeUnit: string;
dgSurchargeMin: string;
// Metadata
remarks: string;
frequency: string;
transitDays: string; transitDays: string;
validFrom: string; validFrom: string;
validUntil: string; validUntil: string;
} }
const REQUIRED_COLUMNS = [ /**
'companyName', * CSV Rate Loader Adapter
'companyEmail', *
'originCFS', * Infrastructure adapter for loading shipping rates from CSV files.
'originCode', * Implements CsvRateLoaderPort interface.
'portOfLoading', *
'routing', * Features:
'destinationCFS', * - CSV parsing with validation
'destinationCode', * - Mapping CSV rows to domain entities
'destinationCountry', * - Error handling and logging
'containerType', * - File system operations
'freightCurrency', */
'freightRatePerCBM',
'freightMinimum',
'fobCurrency',
'fobDocumentation',
'fobISPS',
'fobHandling',
'fobHandlingUnit',
'fobHandlingMinimum',
'fobSolas',
'fobCustoms',
'fobAMS_ACI',
'fobISF5',
'fobDGAdmin',
'dgSurchargeCurrency',
'dgSurchargeRate',
'dgSurchargeUnit',
'dgSurchargeMin',
'remarks',
'frequency',
'transitDays',
'validFrom',
'validUntil',
];
@Injectable() @Injectable()
export class CsvRateLoaderAdapter implements CsvRateLoaderPort { export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
private readonly logger = new Logger(CsvRateLoaderAdapter.name); private readonly logger = new Logger(CsvRateLoaderAdapter.name);
private readonly csvDirectory: string; private readonly csvDirectory: string;
// Company name to CSV file mapping
private readonly companyFileMapping: Map<string, string> = new Map([ private readonly companyFileMapping: Map<string, string> = new Map([
['SSC Consolidation', 'ssc-consolidation.csv'], ['SSC Consolidation', 'ssc-consolidation.csv'],
['ECU Worldwide', 'ecu-worldwide.csv'], ['ECU Worldwide', 'ecu-worldwide.csv'],
@ -119,6 +71,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
@Optional() private readonly configService?: ConfigService, @Optional() private readonly configService?: ConfigService,
@Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository @Optional() private readonly csvConfigRepository?: TypeOrmCsvRateConfigRepository
) { ) {
// CSV files are stored in infrastructure/storage/csv-storage/rates/
// Use absolute path based on project root (works in both dev and production)
// In production, process.cwd() points to the backend app directory
// In development with nest start --watch, it also points to the backend directory
this.csvDirectory = path.join( this.csvDirectory = path.join(
process.cwd(), process.cwd(),
'src', 'src',
@ -128,6 +84,10 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
'rates' 'rates'
); );
this.logger.log(`CSV directory initialized: ${this.csvDirectory}`); this.logger.log(`CSV directory initialized: ${this.csvDirectory}`);
if (this.s3Storage && this.configService) {
this.logger.log('✅ MinIO/S3 storage support enabled for CSV files');
}
} }
async loadRatesFromCsv( async loadRatesFromCsv(
@ -135,32 +95,49 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
companyEmail: string, companyEmail: string,
companyNameOverride?: string companyNameOverride?: string
): Promise<CsvRate[]> { ): Promise<CsvRate[]> {
this.logger.log(`Loading rates from CSV: ${filePath}`); this.logger.log(
`Loading rates from CSV: ${filePath} (email: ${companyEmail}, company: ${companyNameOverride || 'from CSV'})`
);
try { try {
let fileContent: string; let fileContent: string;
// Try to load from MinIO first if configured
if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) { if (this.s3Storage && this.configService && this.csvConfigRepository && companyNameOverride) {
try { try {
const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride); const config = await this.csvConfigRepository.findByCompanyName(companyNameOverride);
const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined; const minioObjectKey = config?.metadata?.minioObjectKey as string | undefined;
if (minioObjectKey) { if (minioObjectKey) {
const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates'); const bucket = this.configService.get<string>('AWS_S3_BUCKET', 'xpeditis-csv-rates');
this.logger.log(`📥 Loading CSV from MinIO: ${bucket}/${minioObjectKey}`);
const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey }); const buffer = await this.s3Storage.download({ bucket, key: minioObjectKey });
fileContent = buffer.toString('utf-8'); fileContent = buffer.toString('utf-8');
this.logger.log(`✅ Successfully loaded CSV from MinIO`);
} else { } else {
throw new Error('No MinIO object key'); // Fallback to local file
throw new Error('No MinIO object key found, using local file');
} }
} catch (minioError: any) { } catch (minioError: any) {
this.logger.warn(`MinIO unavailable: ${minioError.message}. Using local file.`); this.logger.warn(
const fullPath = this.resolvePath(filePath); `⚠️ Failed to load from MinIO: ${minioError.message}. Falling back to local file.`
);
// Fallback to local file system
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
fileContent = await fs.readFile(fullPath, 'utf-8'); fileContent = await fs.readFile(fullPath, 'utf-8');
} }
} else { } else {
const fullPath = this.resolvePath(filePath); // Read from local file system
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
fileContent = await fs.readFile(fullPath, 'utf-8'); fileContent = await fs.readFile(fullPath, 'utf-8');
} }
// Parse CSV
const records: CsvRow[] = parse(fileContent, { const records: CsvRow[] = parse(fileContent, {
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
@ -168,48 +145,62 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
}); });
this.logger.log(`Parsed ${records.length} rows from ${filePath}`); this.logger.log(`Parsed ${records.length} rows from ${filePath}`);
// Validate structure
this.validateCsvStructure(records); this.validateCsvStructure(records);
// Map to domain entities
const rates = records.map((record, index) => { const rates = records.map((record, index) => {
try { try {
return this.mapToCsvRate(record, companyEmail, companyNameOverride); return this.mapToCsvRate(record, companyEmail, companyNameOverride);
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Row ${index + 1} in ${filePath}: ${msg}`); this.logger.error(`Error mapping row ${index + 1} in ${filePath}: ${errorMessage}`);
throw new Error(`Invalid data in row ${index + 1} of ${filePath}: ${errorMessage}`);
} }
}); });
this.logger.log(`Loaded ${rates.length} rates from ${filePath}`); this.logger.log(`Successfully loaded ${rates.length} rates from ${filePath}`);
return rates; return rates;
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to load ${filePath}: ${msg}`); this.logger.error(`Failed to load CSV file ${filePath}: ${errorMessage}`);
throw new Error(`CSV loading failed for ${filePath}: ${msg}`); throw new Error(`CSV loading failed for ${filePath}: ${errorMessage}`);
} }
} }
async loadRatesByCompany(companyName: string): Promise<CsvRate[]> { async loadRatesByCompany(companyName: string): Promise<CsvRate[]> {
const fileName = this.companyFileMapping.get(companyName); const fileName = this.companyFileMapping.get(companyName);
if (!fileName) { if (!fileName) {
this.logger.warn(`No CSV file for company: ${companyName}`); this.logger.warn(`No CSV file configured for company: ${companyName}`);
return []; return [];
} }
const email = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, email); // Use placeholder email since we don't have access to config repository here
const placeholderEmail = `info@${companyName.toLowerCase().replace(/\s+/g, '-')}.com`;
return this.loadRatesFromCsv(fileName, placeholderEmail);
} }
async validateCsvFile( async validateCsvFile(
filePath: string filePath: string
): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> { ): Promise<{ valid: boolean; errors: string[]; rowCount?: number }> {
const errors: string[] = []; const errors: string[] = [];
try { try {
const fullPath = this.resolvePath(filePath); const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(this.csvDirectory, filePath);
// Check if file exists
try { try {
await fs.access(fullPath); await fs.access(fullPath);
} catch { } catch {
return { valid: false, errors: [`File not found: ${filePath}`] }; errors.push(`File not found: ${filePath}`);
return { valid: false, errors };
} }
// Read and parse
const fileContent = await fs.readFile(fullPath, 'utf-8'); const fileContent = await fs.readFile(fullPath, 'utf-8');
const records: CsvRow[] = parse(fileContent, { const records: CsvRow[] = parse(fileContent, {
columns: true, columns: true,
@ -218,154 +209,200 @@ export class CsvRateLoaderAdapter implements CsvRateLoaderPort {
}); });
if (records.length === 0) { if (records.length === 0) {
return { valid: false, errors: ['CSV file is empty'], rowCount: 0 }; errors.push('CSV file is empty');
return { valid: false, errors, rowCount: 0 };
} }
// Validate structure
try { try {
this.validateCsvStructure(records); this.validateCsvStructure(records);
} catch (e) { } catch (error) {
errors.push(e instanceof Error ? e.message : String(e)); const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(errorMessage);
} }
// Validate each row (use dummy email for validation)
records.forEach((record, index) => { records.forEach((record, index) => {
try { try {
this.mapToCsvRate(record, 'validation@example.com'); this.mapToCsvRate(record, 'validation@example.com');
} catch (e) { } catch (error) {
errors.push(`Row ${index + 1}: ${e instanceof Error ? e.message : String(e)}`); const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Row ${index + 1}: ${errorMessage}`);
} }
}); });
return { valid: errors.length === 0, errors, rowCount: records.length };
} catch (e) {
return { return {
valid: false, valid: errors.length === 0,
errors: [`Validation failed: ${e instanceof Error ? e.message : String(e)}`], errors,
rowCount: records.length,
}; };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
errors.push(`Validation failed: ${errorMessage}`);
return { valid: false, errors };
} }
} }
async getAvailableCsvFiles(): Promise<string[]> { async getAvailableCsvFiles(): Promise<string[]> {
try { try {
if (this.s3Storage && this.csvConfigRepository) { // If MinIO/S3 is configured, list files from there
if (this.s3Storage && this.configService && this.csvConfigRepository) {
try { try {
const configs = await this.csvConfigRepository.findAll(); const configs = await this.csvConfigRepository.findAll();
const minioFiles = configs const minioFiles = configs
.filter(c => c.metadata?.minioObjectKey) .filter(config => config.metadata?.minioObjectKey)
.map(c => c.metadata?.minioObjectKey as string); .map(config => config.metadata?.minioObjectKey as string);
if (minioFiles.length > 0) return minioFiles;
} catch { if (minioFiles.length > 0) {
// fall through to local this.logger.log(`📂 Found ${minioFiles.length} CSV files in MinIO`);
return minioFiles;
} else {
this.logger.warn('⚠️ No CSV files configured in MinIO, falling back to local files');
}
} catch (minioError: any) {
this.logger.warn(
`⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
);
} }
} }
// Fallback: list from local file system
try { try {
await fs.access(this.csvDirectory); await fs.access(this.csvDirectory);
} catch { } catch {
this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
return []; return [];
} }
const files = await fs.readdir(this.csvDirectory); const files = await fs.readdir(this.csvDirectory);
return files.filter(f => f.endsWith('.csv')); return files.filter(file => file.endsWith('.csv'));
} catch { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to list CSV files: ${errorMessage}`);
return []; return [];
} }
} }
private resolvePath(filePath: string): string { /**
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath); * Validate that CSV has all required columns
} */
private validateCsvStructure(records: CsvRow[]): void { private validateCsvStructure(records: CsvRow[]): void {
if (records.length === 0) throw new Error('CSV file is empty'); const requiredColumns = [
'companyName',
'origin',
'destination',
'containerType',
'minVolumeCBM',
'maxVolumeCBM',
'minWeightKG',
'maxWeightKG',
'palletCount',
'pricePerCBM',
'pricePerKG',
'basePriceUSD',
'basePriceEUR',
'currency',
'hasSurcharges',
'transitDays',
'validFrom',
'validUntil',
];
if (records.length === 0) {
throw new Error('CSV file is empty');
}
const firstRecord = records[0]; const firstRecord = records[0];
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord)); const missingColumns = requiredColumns.filter(col => !(col in firstRecord));
if (missing.length > 0) {
throw new Error(`Missing required columns: ${missing.join(', ')}`); if (missingColumns.length > 0) {
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
} }
} }
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate { /**
const companyName = companyNameOverride || r.companyName.trim(); * Map CSV row to CsvRate domain entity
// Admin-configured email always takes priority over the value in the CSV row */
const email = companyEmail?.trim() || r.companyEmail?.trim(); private mapToCsvRate(
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges
const surcharges = this.parseSurcharges(record);
const freight: FreightPricing = { // Create DateRange
freightCurrency: r.freightCurrency.toUpperCase(), const validFrom = new Date(record.validFrom);
freightRatePerCBM: parseFloat(r.freightRatePerCBM) || 0, const validUntil = new Date(record.validUntil);
freightMinimum: parseFloat(r.freightMinimum) || 0,
};
const fob: FobCharges = {
fobCurrency: r.fobCurrency.toUpperCase(),
fobDocumentation: parseInt(r.fobDocumentation, 10) || 0,
fobISPS: parseInt(r.fobISPS, 10) || 0,
fobHandling: parseInt(r.fobHandling, 10) || 0,
fobHandlingUnit: (r.fobHandlingUnit?.toUpperCase() === 'W' ? 'W' : 'UP') as HandlingUnit,
fobHandlingMinimum: parseInt(r.fobHandlingMinimum, 10) || 0,
fobSolas: parseInt(r.fobSolas, 10) || 0,
fobCustoms: parseInt(r.fobCustoms, 10) || 0,
fobAMS_ACI: parseFloat(r.fobAMS_ACI) || 0,
fobISF5: parseFloat(r.fobISF5) || 0,
fobDGAdmin: parseInt(r.fobDGAdmin, 10) || 0,
};
const dgSurcharge: DgSurchargeInfo = {
dgSurchargeCurrency: (r.dgSurchargeCurrency || r.fobCurrency).toUpperCase(),
dgSurchargeRate: parseDgValue(r.dgSurchargeRate),
dgSurchargeUnit: (['UP', 'LS', '%'].includes(r.dgSurchargeUnit?.toUpperCase())
? r.dgSurchargeUnit.toUpperCase()
: 'LS') as 'UP' | 'LS' | '%',
dgSurchargeMin: parseDgValue(r.dgSurchargeMin),
};
const validFrom = new Date(r.validFrom);
const validUntil = new Date(r.validUntil);
const validity = DateRange.create(validFrom, validUntil, true); const validity = DateRange.create(validFrom, validUntil, true);
const frequency = parseFrequency(r.frequency); // Use override company name if provided, otherwise use the one from CSV
const companyName = companyNameOverride || record.companyName.trim();
// Create CsvRate
return new CsvRate( return new CsvRate(
companyName, companyName,
email, companyEmail,
r.originCFS.trim(), PortCode.create(record.origin),
PortCode.create(r.originCode.trim()), PortCode.create(record.destination),
r.portOfLoading.trim(), ContainerType.create(record.containerType),
r.routing.trim(), {
r.destinationCFS.trim(), minCBM: parseFloat(record.minVolumeCBM),
PortCode.create(r.destinationCode.trim()), maxCBM: parseFloat(record.maxVolumeCBM),
r.destinationCountry.trim(), },
ContainerType.create(r.containerType.trim()), {
freight, minKG: parseFloat(record.minWeightKG),
fob, maxKG: parseFloat(record.maxWeightKG),
dgSurcharge, },
r.remarks?.trim() || '', parseInt(record.palletCount, 10),
frequency, {
parseInt(r.transitDays, 10), pricePerCBM: parseFloat(record.pricePerCBM),
pricePerKG: parseFloat(record.pricePerKG),
basePriceUSD: Money.create(parseFloat(record.basePriceUSD), 'USD'),
basePriceEUR: Money.create(parseFloat(record.basePriceEUR), 'EUR'),
},
record.currency.toUpperCase(),
new SurchargeCollection(surcharges),
parseInt(record.transitDays, 10),
validity validity
); );
} }
}
function parseDgValue(raw: string): DgSurchargeValue { /**
if (!raw || raw.trim() === '') return 0; * Parse surcharges from CSV row
const upper = raw.trim().toUpperCase(); */
if (upper === 'ON REQUEST') return 'ON REQUEST'; private parseSurcharges(record: CsvRow): Surcharge[] {
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED'; const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
const num = parseFloat(raw);
return isNaN(num) ? 0 : num;
}
function parseFrequency(raw: string): FrequencyType { if (!hasSurcharges) {
switch (raw?.trim()) { return [];
case 'Weekly': }
return 'Weekly';
case 'Bi-Weekly': const surcharges: Surcharge[] = [];
return 'Bi-Weekly'; const currency = record.currency.toUpperCase();
case 'Bi-Monthly':
return 'Bi-Monthly'; // BAF (Bunker Adjustment Factor)
case 'Monthly': if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
return 'Monthly'; surcharges.push(
default: new Surcharge(
return 'Weekly'; SurchargeType.BAF,
Money.create(parseFloat(record.surchargeBAF), currency),
'Bunker Adjustment Factor'
)
);
}
// CAF (Currency Adjustment Factor)
if (record.surchargeCAF && parseFloat(record.surchargeCAF) > 0) {
surcharges.push(
new Surcharge(
SurchargeType.CAF,
Money.create(parseFloat(record.surchargeCAF), currency),
'Currency Adjustment Factor'
)
);
}
return surcharges;
} }
} }

View File

@ -73,9 +73,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
this.buildTransporter(ip, host); this.buildTransporter(ip, host);
return; return;
} catch (err: any) { } catch (err: any) {
this.logger.warn( this.logger.warn(`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`);
`[DNS-DoH] Failed to resolve ${host}: ${err.message} — using hostname directly`
);
} }
} }
@ -89,9 +87,9 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
private resolveViaDoH(hostname: string): Promise<string> { private resolveViaDoH(hostname: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`; 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 = ''; let raw = '';
res.on('data', chunk => (raw += chunk)); res.on('data', (chunk) => (raw += chunk));
res.on('end', () => { res.on('end', () => {
try { try {
const json = JSON.parse(raw); const json = JSON.parse(raw);
@ -138,7 +136,7 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
`Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}` `Email transporter ready — ${serverName}:${port} (IP: ${actualHost}) user: ${user}`
); );
this.transporter.verify(error => { this.transporter.verify((error) => {
if (error) { if (error) {
this.logger.error(`❌ SMTP connection FAILED: ${error.message}`); this.logger.error(`❌ SMTP connection FAILED: ${error.message}`);
} else { } else {
@ -150,7 +148,8 @@ export class EmailAdapter implements EmailPort, OnModuleInit {
async send(options: EmailOptions): Promise<void> { async send(options: EmailOptions): Promise<void> {
try { try {
const from = const from =
options.from ?? this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY); options.from ??
this.configService.get<string>('SMTP_FROM', EMAIL_SENDERS.NOREPLY);
// Génère automatiquement la version plain text si absente (améliore le score anti-spam) // 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); const text = options.text ?? (options.html ? htmlToPlainText(options.html) : undefined);

View File

@ -1,19 +0,0 @@
/**
* 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;
}
}

View File

@ -75,24 +75,11 @@ export class CsvBookingOrmEntity {
@Column({ @Column({
name: 'status', name: 'status',
type: 'enum', type: 'enum',
enum: [ enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
'PENDING_PAYMENT',
'PENDING_BANK_TRANSFER',
'PENDING',
'ACCEPTED',
'REJECTED',
'CANCELLED',
],
default: 'PENDING_PAYMENT', default: 'PENDING_PAYMENT',
}) })
@Index() @Index()
status: status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
| 'PENDING_PAYMENT'
| 'PENDING_BANK_TRANSFER'
| 'PENDING'
| 'ACCEPTED'
| 'REJECTED'
| 'CANCELLED';
@Column({ name: 'documents', type: 'jsonb' }) @Column({ name: 'documents', type: 'jsonb' })
documents: Array<{ documents: Array<{

View File

@ -74,9 +74,6 @@ export class OrganizationOrmEntity {
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean; isActive: boolean;
@Column({ name: 'default_language', type: 'varchar', length: 2, default: 'fr' })
defaultLanguage: string;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@ -1,4 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
@Entity('password_reset_tokens') @Entity('password_reset_tokens')
export class PasswordResetTokenOrmEntity { export class PasswordResetTokenOrmEntity {

View File

@ -62,9 +62,6 @@ export class UserOrmEntity {
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true }) @Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
lastLoginAt: Date | null; lastLoginAt: Date | null;
@Column({ name: 'preferred_language', type: 'varchar', length: 2, default: 'fr' })
preferredLanguage: string;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt: Date; createdAt: Date;

View File

@ -5,7 +5,6 @@
*/ */
import { Organization, OrganizationProps } from '@domain/entities/organization.entity'; import { Organization, OrganizationProps } from '@domain/entities/organization.entity';
import { toLocale } from '@domain/value-objects/locale.vo';
import { OrganizationOrmEntity } from '../entities/organization.orm-entity'; import { OrganizationOrmEntity } from '../entities/organization.orm-entity';
export class OrganizationOrmMapper { export class OrganizationOrmMapper {
@ -35,7 +34,6 @@ export class OrganizationOrmMapper {
orm.siretVerified = props.siretVerified; orm.siretVerified = props.siretVerified;
orm.statusBadge = props.statusBadge; orm.statusBadge = props.statusBadge;
orm.isActive = props.isActive; orm.isActive = props.isActive;
orm.defaultLanguage = props.defaultLanguage;
orm.createdAt = props.createdAt; orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt; orm.updatedAt = props.updatedAt;
@ -68,7 +66,6 @@ export class OrganizationOrmMapper {
siretVerified: orm.siretVerified ?? false, siretVerified: orm.siretVerified ?? false,
statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none', statusBadge: (orm.statusBadge as 'none' | 'silver' | 'gold' | 'platinium') || 'none',
isActive: orm.isActive, isActive: orm.isActive,
defaultLanguage: toLocale(orm.defaultLanguage),
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,
}; };

View File

@ -5,10 +5,7 @@
*/ */
import { Subscription } from '@domain/entities/subscription.entity'; import { Subscription } from '@domain/entities/subscription.entity';
import { import { SubscriptionOrmEntity, SubscriptionPlanOrmType } from '../entities/subscription.orm-entity';
SubscriptionOrmEntity,
SubscriptionPlanOrmType,
} from '../entities/subscription.orm-entity';
/** Maps canonical domain plan names back to the values stored in the DB. */ /** Maps canonical domain plan names back to the values stored in the DB. */
const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = { const DOMAIN_TO_ORM_PLAN: Record<string, SubscriptionPlanOrmType> = {

View File

@ -5,7 +5,6 @@
*/ */
import { User, UserProps } from '@domain/entities/user.entity'; import { User, UserProps } from '@domain/entities/user.entity';
import { toLocale } from '@domain/value-objects/locale.vo';
import { UserOrmEntity } from '../entities/user.orm-entity'; import { UserOrmEntity } from '../entities/user.orm-entity';
export class UserOrmMapper { export class UserOrmMapper {
@ -28,7 +27,6 @@ export class UserOrmMapper {
orm.isEmailVerified = props.isEmailVerified; orm.isEmailVerified = props.isEmailVerified;
orm.isActive = props.isActive; orm.isActive = props.isActive;
orm.lastLoginAt = props.lastLoginAt || null; orm.lastLoginAt = props.lastLoginAt || null;
orm.preferredLanguage = props.preferredLanguage;
orm.createdAt = props.createdAt; orm.createdAt = props.createdAt;
orm.updatedAt = props.updatedAt; orm.updatedAt = props.updatedAt;
@ -52,7 +50,6 @@ export class UserOrmMapper {
isEmailVerified: orm.isEmailVerified, isEmailVerified: orm.isEmailVerified,
isActive: orm.isActive, isActive: orm.isActive,
lastLoginAt: orm.lastLoginAt || undefined, lastLoginAt: orm.lastLoginAt || undefined,
preferredLanguage: toLocale(orm.preferredLanguage),
createdAt: orm.createdAt, createdAt: orm.createdAt,
updatedAt: orm.updatedAt, updatedAt: orm.updatedAt,
}; };

View File

@ -38,9 +38,15 @@ export class CreateApiKeysTable1741000000001 implements MigrationInterface {
await queryRunner.query( await queryRunner.query(
`CREATE INDEX "idx_api_keys_organization_id" ON "api_keys" ("organization_id")` `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(
await queryRunner.query(`CREATE INDEX "idx_api_keys_is_active" ON "api_keys" ("is_active")`); `CREATE INDEX "idx_api_keys_user_id" ON "api_keys" ("user_id")`
await queryRunner.query(`CREATE INDEX "idx_api_keys_key_hash" ON "api_keys" ("key_hash")`); );
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( await queryRunner.query(
`COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'` `COMMENT ON TABLE "api_keys" IS 'API keys for programmatic access — GOLD and PLATINIUM plans only'`

View File

@ -1,25 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPreferredLanguage1745000000000 implements MigrationInterface {
name = 'AddPreferredLanguage1745000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE "organizations" DROP COLUMN "default_language"
`);
await queryRunner.query(`
ALTER TABLE "users" DROP COLUMN "preferred_language"
`);
}
}

View File

@ -1,14 +1,12 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { VersioningType } from '@nestjs/common'; import { ValidationPipe, VersioningType } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { I18nService, I18nValidationExceptionFilter, I18nValidationPipe } from 'nestjs-i18n';
import helmet from 'helmet'; import helmet from 'helmet';
import compression from 'compression'; import compression from 'compression';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { helmetConfig, corsConfig } from './infrastructure/security/security.config'; import { helmetConfig, corsConfig } from './infrastructure/security/security.config';
import { DomainExceptionFilter } from './application/filters/domain-exception.filter';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
async function bootstrap() { async function bootstrap() {
@ -44,9 +42,9 @@ async function bootstrap() {
type: VersioningType.URI, type: VersioningType.URI,
}); });
// Global validation pipe — i18n-aware (messages translated to caller locale) // Global validation pipe
app.useGlobalPipes( app.useGlobalPipes(
new I18nValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
transform: true, transform: true,
@ -56,15 +54,6 @@ 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<Record<string, unknown>>;
app.useGlobalFilters(
new DomainExceptionFilter(i18nService),
new I18nValidationExceptionFilter({ detailedErrors: false })
);
// ─── Swagger documentation ──────────────────────────────────────────────── // ─── Swagger documentation ────────────────────────────────────────────────
const swaggerUser = configService.get<string>('SWAGGER_USERNAME'); const swaggerUser = configService.get<string>('SWAGGER_USERNAME');
const swaggerPass = configService.get<string>('SWAGGER_PASSWORD'); const swaggerPass = configService.get<string>('SWAGGER_PASSWORD');

View File

@ -1,107 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Shield } from 'lucide-react';
export default async function AssurancePage() {
const t = await getTranslations('dashboard.wikiPages');
const clauses = t.raw('assurance.clauses') as Array<{
name: string; level: string; includes: string[]; excludes: string[];
}>;
const extensions = t.raw('assurance.extensions') as Array<{ name: string; description: string }>;
const processSteps = t.raw('assurance.processSteps') as string[];
const clauseColors = ['border-green-500 bg-green-50', 'border-yellow-500 bg-yellow-50', 'border-red-500 bg-red-50'];
const clauseBadges = ['bg-green-600', 'bg-yellow-600', 'bg-red-600'];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Shield className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('assurance.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('assurance.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.iccTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{clauses.map((clause, i) => (
<Card key={clause.name} className={`border-2 ${clauseColors[i]}`}>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold">{clause.name}</h3>
<span className={`px-2 py-1 rounded text-white text-sm ${clauseBadges[i]}`}>{clause.level}</span>
</div>
<div className="mb-3">
<h4 className="text-sm font-semibold text-green-700 mb-1">{t('includesLabel')}</h4>
<ul className="text-sm space-y-1">
{clause.includes.map((item, j) => (
<li key={j} className="flex items-start gap-1 text-gray-700">
<span className="text-green-500 mt-0.5"></span> {item}
</li>
))}
</ul>
</div>
<div>
<h4 className="text-sm font-semibold text-red-700 mb-1">{t('excludesLabel')}</h4>
<ul className="text-sm space-y-1">
{clause.excludes.map((item, j) => (
<li key={j} className="flex items-start gap-1 text-gray-700">
<span className="text-red-500 mt-0.5"></span> {item}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensions.map((ext) => (
<Card key={ext.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{ext.name}</h4>
<p className="text-sm text-gray-600 mt-1">{ext.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('assurance.processTitle')}</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
{processSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</CardContent>
</Card>
<Card className="mt-4 bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3>
<p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">{t('assurance.valueFormula')}</p>
<p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,100 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Calculator } from 'lucide-react';
export default async function CalculFretPage() {
const t = await getTranslations('dashboard.wikiPages');
const surcharges = t.raw('calculFret.surcharges') as Array<{
code: string; name: string; description: string; variation: string;
}>;
const additionalCosts = t.raw('calculFret.additionalCosts') as Array<{
name: string; description: string; typical: string;
}>;
const exampleItems = t.raw('calculFret.exampleItems') as Array<{ item: string; amount: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Calculator className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('calculFret.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('calculFret.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('calculFret.surchargesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('calculFret.colCode')}</th>
<th className="text-left p-3 font-medium">{t('calculFret.colName')}</th>
<th className="text-left p-3 font-medium">{t('calculFret.colDescription')}</th>
<th className="text-left p-3 font-medium">{t('calculFret.colVariation')}</th>
</tr>
</thead>
<tbody>
{surcharges.map((s) => (
<tr key={s.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{s.code}</td>
<td className="p-3 font-medium">{s.name}</td>
<td className="p-3 text-gray-600">{s.description}</td>
<td className="p-3 text-gray-500 text-xs">{s.variation}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('calculFret.additionalCostsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalCosts.map((cost) => (
<Card key={cost.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{cost.name}</h4>
<p className="text-sm text-gray-600 mt-1">{cost.description}</p>
<p className="text-xs text-gray-400 mt-2">{t('calculFret.colCost')}: {cost.typical}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-4">{t('calculFret.exampleTitle')}</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 font-medium">{t('calculFret.colItem')}</th>
<th className="text-right py-2 font-medium">{t('calculFret.colAmount')}</th>
</tr>
</thead>
<tbody>
{exampleItems.map((item, i) => (
<tr key={i} className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}>
<td className="py-2">{item.item}</td>
<td className="py-2 text-right font-mono">{item.amount}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,102 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Package } from 'lucide-react';
export default async function ConteneursPage() {
const t = await getTranslations('dashboard.wikiPages');
const containers = t.raw('conteneurs.containers') as Array<{
type: string; description: string; internal: string; door: string; volume: string; payload: string;
}>;
const specialEquipment = t.raw('conteneurs.specialEquipment') as Array<{ name: string; description: string }>;
const selectionGuide = t.raw('conteneurs.selectionGuide') as Array<{ condition: string; recommendation: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Package className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('conteneurs.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('conteneurs.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('conteneurs.standardTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('conteneurs.colDimensions')}</th>
<th className="text-left p-3 font-medium">{t('conteneurs.colInternal')}</th>
<th className="text-left p-3 font-medium">{t('conteneurs.colDoor')}</th>
<th className="text-left p-3 font-medium">{t('conteneurs.colVolume')}</th>
<th className="text-left p-3 font-medium">{t('conteneurs.colPayload')}</th>
</tr>
</thead>
<tbody>
{containers.map((c) => (
<tr key={c.type} className="border-t hover:bg-gray-50">
<td className="p-3">
<div className="font-semibold text-gray-900">{c.type}</div>
<div className="text-xs text-gray-500">{c.description}</div>
</td>
<td className="p-3 font-mono text-xs">{c.internal}</td>
<td className="p-3 font-mono text-xs">{c.door}</td>
<td className="p-3 font-mono text-xs text-blue-600">{c.volume}</td>
<td className="p-3 font-mono text-xs">{c.payload}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('conteneurs.specialEquipmentTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{specialEquipment.map((eq) => (
<Card key={eq.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{eq.name}</h4>
<p className="text-sm text-gray-600 mt-1">{eq.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-4">{t('conteneurs.selectionTitle')}</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-blue-200">
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colCondition')}</th>
<th className="text-left py-2 font-medium text-blue-800">{t('conteneurs.colRecommendation')}</th>
</tr>
</thead>
<tbody>
{selectionGuide.map((row, i) => (
<tr key={i} className="border-b border-blue-100 last:border-0">
<td className="py-2 text-blue-700">{row.condition}</td>
<td className="py-2 font-medium text-blue-900">{row.recommendation}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,89 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ClipboardList } from 'lucide-react';
export default async function DocumentsTransportPage() {
const t = await getTranslations('dashboard.wikiPages');
const documents = t.raw('documentsTransport.documents') as Array<{
name: string; type: string; description: string; types: string[];
}>;
const additionalDocs = t.raw('documentsTransport.additionalDocs') as Array<{ name: string; description: string }>;
const blFunctions = t.raw('documentsTransport.blFunctions') as Array<{ title: string; description: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<ClipboardList className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('documentsTransport.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('documentsTransport.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.mainDocumentsTitle')}</h2>
<div className="space-y-4">
{documents.map((doc) => (
<Card key={doc.name} className="bg-white">
<CardContent className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">{doc.type}</span>
</div>
<p className="text-sm text-gray-600 mb-2">{doc.description}</p>
<div className="flex flex-wrap gap-1">
{doc.types.map((type, j) => (
<span key={j} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{type}</span>
))}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('documentsTransport.additionalDocsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalDocs.map((doc) => (
<Card key={doc.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<p className="text-sm text-gray-600 mt-1">{doc.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-4">{t('documentsTransport.blFocusTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{blFunctions.map((fn) => (
<div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4>
<p className="text-sm text-gray-600">{fn.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,94 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ShieldCheck } from 'lucide-react';
export default async function DouanesPage() {
const t = await getTranslations('dashboard.wikiPages');
const regimes = t.raw('douanes.regimes') as Array<{ code: string; name: string; description: string }>;
const documents = t.raw('douanes.documents') as Array<{
name: string; mandatory: boolean; description: string;
}>;
const duties = t.raw('douanes.duties') as Array<{ type: string; description: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<ShieldCheck className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('douanes.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('douanes.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.regimesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('douanes.colCode')}</th>
<th className="text-left p-3 font-medium">{t('douanes.colName')}</th>
<th className="text-left p-3 font-medium">{t('douanes.colDescription')}</th>
</tr>
</thead>
<tbody>
{regimes.map((r) => (
<tr key={r.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{r.code}</td>
<td className="p-3 font-medium">{r.name}</td>
<td className="p-3 text-gray-600">{r.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map((doc) => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-3">
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
{doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')}
</span>
<div>
<span className="font-medium text-gray-900">{doc.name}</span>
<span className="text-sm text-gray-500 ml-2"> {doc.description}</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{duties.map((d) => (
<Card key={d.type} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4>
<p className="text-sm text-gray-600">{d.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</div>
);
}

View File

@ -1,107 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle } from 'lucide-react';
export default async function ImdgPage() {
const t = await getTranslations('dashboard.wikiPages');
const classes = t.raw('imdg.classes') as Array<{
class: string; name: string; description: string; subdivisions?: string[];
}>;
const documents = t.raw('imdg.documents') as Array<{ name: string; description: string }>;
const packagingGroups = t.raw('imdg.packagingGroups') as Array<{ group: string; description: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<AlertTriangle className="w-10 h-10 text-orange-500" />
<h1 className="text-3xl font-bold text-gray-900">{t('imdg.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('imdg.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map((cls) => (
<Card key={cls.class} className="bg-white border-orange-200">
<CardContent className="pt-4">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">{cls.class}</span>
<h4 className="font-semibold text-gray-900">{cls.name}</h4>
</div>
<p className="text-sm text-gray-600">{cls.description}</p>
{cls.subdivisions && (
<div className="mt-2">
<p className="text-xs font-medium text-gray-500 mb-1">{t('imdg.subdivisionsLabel')}</p>
<ul className="text-xs text-gray-600 space-y-0.5">
{cls.subdivisions.map((sub, j) => (
<li key={j}> {sub}</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map((doc) => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-3">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<p className="text-sm text-gray-600 mt-1">{doc.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packagingGroups.map((pg, i) => {
const colors = ['bg-red-50 border-red-200', 'bg-yellow-50 border-yellow-200', 'bg-green-50 border-green-200'];
return (
<Card key={pg.group} className={`border ${colors[i]}`}>
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{pg.group}</h4>
<p className="text-sm text-gray-600 mt-1">{pg.description}</p>
</CardContent>
</Card>
);
})}
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('imdg.labelingTitle')}</h3>
<p className="text-sm text-blue-800">{t('imdg.labelingContent')}</p>
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-2">{t('imdg.segregationTitle')}</h3>
<p className="text-sm text-gray-700">{t('imdg.segregationContent')}</p>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,112 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ScrollText } from 'lucide-react';
export default async function IncotermsPage() {
const t = await getTranslations('dashboard.wikiPages');
const list = t.raw('incoterms.list') as Array<{
code: string; name: string; description: string; risk: string; transport: string;
}>;
const categorySections = t.raw('incoterms.categorySections') as Array<{
name: string; description: string; terms: string[];
}>;
const keyPoints = t.raw('incoterms.keyPoints') as string[];
const tips = t.raw('incoterms.tips') as string[];
const categoryColors = ['bg-green-100 text-green-800', 'bg-red-100 text-red-800', 'bg-blue-100 text-blue-800'];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<ScrollText className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('incoterms.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('incoterms.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('incoterms.keyPointsTitle')}</h3>
<ul className="space-y-2">
{keyPoints.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-blue-800">
<span className="mt-1 w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
<span className="text-sm">{point}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('incoterms.categoriesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{categorySections.map((cat, i) => (
<Card key={i} className="bg-white">
<CardContent className="pt-4">
<h3 className="font-semibold text-gray-900">{cat.name}</h3>
<p className="text-sm text-gray-600 mt-1">{cat.description}</p>
<div className="flex flex-wrap gap-1 mt-3">
{cat.terms.map((term) => (
<span key={term} className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${categoryColors[i]}`}>{term}</span>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('incoterms.tableTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('incoterms.colCode')}</th>
<th className="text-left p-3 font-medium">{t('incoterms.colName')}</th>
<th className="text-left p-3 font-medium">{t('incoterms.colDescription')}</th>
<th className="text-left p-3 font-medium">{t('incoterms.colRisk')}</th>
<th className="text-left p-3 font-medium">{t('incoterms.colTransport')}</th>
</tr>
</thead>
<tbody>
{list.map((item) => (
<tr key={item.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{item.code}</td>
<td className="p-3 font-medium">{item.name}</td>
<td className="p-3 text-gray-600">{item.description}</td>
<td className="p-3 text-gray-500">{item.risk}</td>
<td className="p-3 text-gray-500">{item.transport}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">{t('incoterms.tipsTitle')}</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
{tips.map((tip, i) => (
<li key={i}>{tip}</li>
))}
</ul>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,117 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Scale } from 'lucide-react';
export default async function LclVsFclPage() {
const t = await getTranslations('dashboard.wikiPages');
const criteria = t.raw('lclVsFcl.criteria') as Array<{ criterion: string; lcl: string; fcl: string }>;
const lclProcess = t.raw('lclVsFcl.lclProcess') as Array<{ step: string; title: string; description: string }>;
const chooseLcl = t.raw('lclVsFcl.chooseLcl') as string[];
const chooseFcl = t.raw('lclVsFcl.chooseFcl') as string[];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Scale className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('lclVsFcl.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('lclVsFcl.description')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="text-lg font-bold text-blue-900 mb-2">{t('lclVsFcl.lclTitle')}</h3>
<p className="text-sm text-blue-800">{t('lclVsFcl.lclDescription')}</p>
</CardContent>
</Card>
<Card className="bg-green-50 border-green-200">
<CardContent className="pt-6">
<h3 className="text-lg font-bold text-green-900 mb-2">{t('lclVsFcl.fclTitle')}</h3>
<p className="text-sm text-green-800">{t('lclVsFcl.fclDescription')}</p>
</CardContent>
</Card>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.comparisonTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('lclVsFcl.colCriterion')}</th>
<th className="text-left p-3 font-medium text-blue-600">{t('lclVsFcl.colLcl')}</th>
<th className="text-left p-3 font-medium text-green-600">{t('lclVsFcl.colFcl')}</th>
</tr>
</thead>
<tbody>
{criteria.map((row) => (
<tr key={row.criterion} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-gray-900">{row.criterion}</td>
<td className="p-3 text-gray-600">{row.lcl}</td>
<td className="p-3 text-gray-600">{row.fcl}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
<div className="space-y-3">
{lclProcess.map((step) => (
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
{step.step}
</div>
<div>
<h4 className="font-medium text-gray-900">{step.title}</h4>
<p className="text-sm text-gray-600">{step.description}</p>
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
<ul className="space-y-2">
{chooseLcl.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-blue-800">
<span className="text-blue-500 mt-0.5"></span> {item}
</li>
))}
</ul>
</CardContent>
</Card>
<Card className="bg-green-50 border-green-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-green-900 mb-3">{t('lclVsFcl.chooseFclTitle')}</h3>
<ul className="space-y-2">
{chooseFcl.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-green-800">
<span className="text-green-500 mt-0.5"></span> {item}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,136 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { CreditCard } from 'lucide-react';
export default async function LettreCreditPage() {
const t = await getTranslations('dashboard.wikiPages');
const types = t.raw('lettreCredit.types') as Array<{ name: string; description: string }>;
const parties = t.raw('lettreCredit.parties') as Array<{ role: string; description: string }>;
const documents = t.raw('lettreCredit.documents') as Array<{ name: string; description: string }>;
const errors = t.raw('lettreCredit.errors') as string[];
const datesItems = t.raw('lettreCredit.datesItems') as Array<{ label: string; description: string }>;
const costsItems = t.raw('lettreCredit.costsItems') as Array<{ label: string; description: string }>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('lettreCredit.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('lettreCredit.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{types.map((type) => (
<Card key={type.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{type.name}</h4>
<p className="text-sm text-gray-600 mt-1">{type.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.partiesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('lettreCredit.colRole')}</th>
<th className="text-left p-3 font-medium">Description</th>
</tr>
</thead>
<tbody>
{parties.map((p) => (
<tr key={p.role} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-blue-700">{p.role}</td>
<td className="p-3 text-gray-600">{p.description}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map((doc) => (
<div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border">
<span className="text-green-500 mt-0.5 flex-shrink-0"></span>
<div>
<span className="font-medium text-gray-900">{doc.name}</span>
<span className="text-sm text-gray-500 ml-2"> {doc.description}</span>
</div>
</div>
))}
</div>
</div>
<Card className="mt-8 bg-red-50 border-red-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-red-900 mb-3">{t('lettreCredit.errorsTitle')}</h3>
<ul className="space-y-2">
{errors.map((err, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-red-800">
<span className="text-red-500 mt-0.5 flex-shrink-0"></span> {err}
</li>
))}
</ul>
</CardContent>
</Card>
<Card className="mt-4 bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('lettreCredit.ucp600Title')}</h3>
<p className="text-sm text-blue-800">{t('lettreCredit.ucp600Content')}</p>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-white">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
<div className="space-y-3">
{datesItems.map((item) => (
<div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="bg-white">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
<div className="space-y-3">
{costsItems.map((item) => (
<div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,102 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import {
ScrollText,
ClipboardList,
Package,
Scale,
ShieldCheck,
Shield,
Calculator,
Globe,
Anchor,
AlertTriangle,
CreditCard,
Timer,
type LucideIcon,
} from 'lucide-react';
type TopicKey = 'incoterms' | 'documents' | 'containers' | 'lclFcl' | 'customs' | 'insurance' | 'freight' | 'ports' | 'vgm' | 'imdg' | 'letterOfCredit' | 'transitTime';
interface WikiTopic {
key: TopicKey;
icon: LucideIcon;
href: string;
tags: string[];
}
const wikiTopics: WikiTopic[] = [
{ key: 'incoterms', icon: ScrollText, href: '/dashboard/wiki/incoterms', tags: ['FOB', 'CIF', 'EXW', 'DDP'] },
{ key: 'documents', icon: ClipboardList, href: '/dashboard/wiki/documents-transport', tags: ['B/L', 'Sea Waybill', 'Manifest'] },
{ key: 'containers', icon: Package, href: '/dashboard/wiki/conteneurs', tags: ["20'", "40'", 'Reefer', 'Open Top'] },
{ key: 'lclFcl', icon: Scale, href: '/dashboard/wiki/lcl-vs-fcl', tags: ['LCL', 'FCL'] },
{ key: 'customs', icon: ShieldCheck, href: '/dashboard/wiki/douanes', tags: ['HS Code', 'EUR.1', 'AEO'] },
{ key: 'insurance', icon: Shield, href: '/dashboard/wiki/assurance', tags: ['ICC A', 'ICC B', 'ICC C'] },
{ key: 'freight', icon: Calculator, href: '/dashboard/wiki/calcul-fret', tags: ['CBM', 'THC', 'BAF', 'CAF'] },
{ key: 'ports', icon: Globe, href: '/dashboard/wiki/ports-routes', tags: ['Hub', 'Suez', 'Panama'] },
{ key: 'vgm', icon: Anchor, href: '/dashboard/wiki/vgm', tags: ['SOLAS', 'VGM'] },
{ key: 'imdg', icon: AlertTriangle, href: '/dashboard/wiki/imdg', tags: ['IMDG', 'MSDS', 'DG'] },
{ key: 'letterOfCredit', icon: CreditCard, href: '/dashboard/wiki/lettre-credit', tags: ['L/C', 'SWIFT', 'UCP 600'] },
{ key: 'transitTime', icon: Timer, href: '/dashboard/wiki/transit-time', tags: ['Cut-off', 'Free time', 'Demurrage'] },
];
export default async function WikiPage() {
const t = await getTranslations('dashboard.wiki');
return (
<div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-2 text-gray-600">{t('description')}</p>
</div>
{/* Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{wikiTopics.map((topic) => {
const IconComponent = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
<CardHeader>
<div className="flex items-start justify-between">
<div className="h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center">
<IconComponent className="h-5 w-5 text-blue-600" />
</div>
</div>
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
{t(`topics.${topic.key}.title` as any)}
</CardTitle>
<CardDescription className="text-gray-600">
{t(`topics.${topic.key}.description` as any)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{topic.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded-full"
>
{tag}
</span>
))}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
{/* Footer info */}
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
<span className="font-semibold">{t('needHelp')} </span>
{t('helpText')}
</p>
</div>
</div>
);
}

View File

@ -1,123 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Globe } from 'lucide-react';
export default async function PortsRoutesPage() {
const t = await getTranslations('dashboard.wikiPages');
const routes = t.raw('portsRoutes.routes') as Array<{
name: string; description: string; via: string; transitTime: string; majorPorts: string[];
}>;
const passages = t.raw('portsRoutes.passages') as Array<{
name: string; location: string; length: string; description: string; keyStat: string;
}>;
const ports = t.raw('portsRoutes.ports') as Array<{
rank: number; port: string; country: string; teu: string;
}>;
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Globe className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('portsRoutes.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('portsRoutes.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.majorRoutesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{routes.map((route) => (
<Card key={route.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
<p className="text-sm text-gray-600 mb-3">{route.description}</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">{t('portsRoutes.colVia')}: <strong className="text-gray-800">{route.via}</strong></span>
<span className="text-gray-500">{t('portsRoutes.colTransit')}: <strong className="text-blue-600">{route.transitTime}</strong></span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{route.majorPorts.map((port) => (
<span key={port} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">{port}</span>
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.passagesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{passages.map((p) => (
<Card key={p.name} className="bg-white border-blue-200">
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-gray-900">{p.name}</h4>
<p className="text-sm text-gray-500">{p.location} {t('portsRoutes.colLength')}: {p.length}</p>
</div>
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">{p.keyStat}</span>
</div>
<p className="text-sm text-gray-600">{p.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.portsTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-3 font-medium">{t('portsRoutes.colRank')}</th>
<th className="text-left p-3 font-medium">{t('portsRoutes.colPort')}</th>
<th className="text-left p-3 font-medium">{t('portsRoutes.colCountry')}</th>
<th className="text-right p-3 font-medium">{t('portsRoutes.colTeu')}</th>
</tr>
</thead>
<tbody>
{ports.map((port) => (
<tr key={port.rank} className="border-t hover:bg-gray-50">
<td className="p-3 font-bold text-gray-400">#{port.rank}</td>
<td className="p-3 font-medium text-gray-900">{port.port}</td>
<td className="p-3 text-gray-600">{port.country}</td>
<td className="p-3 text-right font-mono text-blue-600">{port.teu}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('portsRoutes.hubTitle')}</h3>
<p className="text-sm text-blue-800">{t('portsRoutes.hubDescription')}</p>
</CardContent>
</Card>
<Card className="bg-green-50 border-green-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-green-900 mb-2">{t('portsRoutes.gatewayTitle')}</h3>
<p className="text-sm text-green-800">{t('portsRoutes.gatewayDescription')}</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,203 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Clock } from 'lucide-react';
export default async function TransitTimePage() {
const t = await getTranslations('dashboard.wikiPages');
const timeline = t.raw('transitTime.timeline') as Array<{
step: string; description: string; delay: string; responsible: string;
}>;
const transitTimes = t.raw('transitTime.transitTimes') as Array<{ route: string; time: string; via: string }>;
const lateFees = t.raw('transitTime.lateFees') as Array<{
name: string; definition: string; rate: string; location: string;
}>;
const potentialDelays = t.raw('transitTime.potentialDelays') as string[];
const seasonalVariations = t.raw('transitTime.seasonalVariations') as string[];
const rolloverCauses = t.raw('transitTime.rolloverCauses') as string[];
const tips = t.raw('transitTime.tips') as string[];
const keyTerms = [
{ key: 'ETD', def: t('transitTime.etd') },
{ key: 'ETA', def: t('transitTime.eta') },
{ key: 'Cut-off', def: t('transitTime.cutoff') },
{ key: 'Free time', def: t('transitTime.freeTime') },
];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Clock className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('transitTime.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('transitTime.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('transitTime.keyTermsTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{keyTerms.map((term) => (
<div key={term.key}>
<h4 className="font-medium text-blue-800">{term.key}</h4>
<p className="text-sm text-blue-700">{term.def}</p>
</div>
))}
</div>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2>
<div className="space-y-3">
{timeline.map((item, index) => (
<Card key={index} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">{item.step}</h4>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{item.delay}</span>
</div>
<p className="text-sm text-gray-600">{item.description}</p>
<p className="text-xs text-gray-400 mt-1">{t('transitTime.responsibleLabel')} : {item.responsible}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.transitTimesTitle')}</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 font-medium">{t('transitTime.colRoute')}</th>
<th className="text-center py-2 font-medium">{t('transitTime.colTime')}</th>
<th className="text-right py-2 font-medium">{t('transitTime.colVia')}</th>
</tr>
</thead>
<tbody>
{transitTimes.map((tt) => (
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 text-gray-900">{tt.route}</td>
<td className="py-3 text-center font-mono text-blue-600">{tt.time}</td>
<td className="py-3 text-right text-gray-500">{tt.via}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.transitNote')}</p>
</CardContent>
</Card>
</div>
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('transitTime.freeTimeTitle')}</h3>
<p className="text-gray-600 mb-4">{t('transitTime.freeTimeDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.freeTimeStandard')}</h4>
<p className="text-2xl font-bold text-blue-600">{t('transitTime.freeTimeValue')}</p>
<p className="text-xs text-gray-500">{t('transitTime.freeTimeNote')}</p>
</div>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.demurrageStart')}</h4>
<p className="text-sm text-gray-600">{t('transitTime.demurrageStartDesc')}</p>
</div>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.detentionStart')}</h4>
<p className="text-sm text-gray-600">{t('transitTime.detentionStartDesc')}</p>
</div>
</div>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lateFees.map((fee) => (
<Card key={fee.name} className="bg-white border-red-200">
<CardContent className="pt-4">
<h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4>
<p className="text-gray-600 text-sm mb-2">{fee.definition}</p>
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('transitTime.colRate')} :</span>
<span className="font-mono text-red-600">{fee.rate}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-500">{t('transitTime.colLocation')} :</span>
<span className="text-gray-700">{fee.location}</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-orange-50 border-orange-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-orange-900 mb-3">{t('transitTime.delayFactorsTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium text-orange-800">{t('transitTime.potentialDelaysTitle')}</h4>
<ul className="text-sm text-orange-700 mt-2 space-y-1">
{potentialDelays.map((d, i) => <li key={i}> {d}</li>)}
</ul>
</div>
<div>
<h4 className="font-medium text-orange-800">{t('transitTime.seasonalVariationsTitle')}</h4>
<ul className="text-sm text-orange-700 mt-2 space-y-1">
{seasonalVariations.map((v, i) => <li key={i}> {v}</li>)}
</ul>
</div>
</div>
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('transitTime.rolloverTitle')}</h3>
<p className="text-gray-600 mb-3">{t('transitTime.rolloverDescription')}</p>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
<ul className="text-sm text-gray-600 mt-2 space-y-1">
{rolloverCauses.map((cause, i) => <li key={i}> {cause}</li>)}
</ul>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
</div>
</CardContent>
</Card>
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">{t('transitTime.tipsTitle')}</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
</ul>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,179 +0,0 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Anchor } from 'lucide-react';
export default async function VGMPage() {
const t = await getTranslations('dashboard.wikiPages');
const why = t.raw('vgm.why') as Array<{ title: string; description: string }>;
const elements = t.raw('vgm.elements') as Array<{ element: string; description: string; example: string }>;
const methods = t.raw('vgm.methods') as Array<{
method: string; name: string; description: string;
process: string[]; advantages: string[]; disadvantages: string[];
}>;
const responsibilities = t.raw('vgm.responsibilities') as Array<{ role: string; description: string }>;
const sanctions = t.raw('vgm.sanctions') as Array<{ region: string; sanction: string }>;
const tips = t.raw('vgm.tips') as string[];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link href="/dashboard/wiki" className="flex items-center text-blue-600 hover:text-blue-800 transition-colors">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
{t('backToWiki')}
</Link>
</div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Anchor className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('vgm.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('vgm.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
{why.map((item) => (
<div key={item.title}>
<h4 className="font-medium">{item.title}</h4>
<p className="text-sm mt-0.5">{item.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.componentsTitle')}</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">{t('vgm.formula')}</span>
</div>
<div className="space-y-3">
{elements.map((item) => (
<div key={item.element} className="flex items-center justify-between py-3 border-b last:border-0">
<div>
<h4 className="font-medium text-gray-900">{item.element}</h4>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">{item.example}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
<div className="space-y-4">
{methods.map((method) => (
<Card key={method.method} className="bg-white">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-3">
<span className="px-3 py-1 bg-green-600 text-white rounded-md text-sm">{method.method}</span>
<span className="text-lg">{method.name}</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-4">{method.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
{method.process.map((step, i) => <li key={i}>{step}</li>)}
</ol>
</div>
<div>
<h4 className="font-medium text-green-700 mb-2"> {t('vgm.advantagesLabel')}</h4>
<ul className="text-sm text-gray-600 space-y-1">
{method.advantages.map((adv) => (
<li key={adv} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />{adv}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-medium text-red-700 mb-2"> {t('vgm.disadvantagesLabel')}</h4>
<ul className="text-sm text-gray-600 space-y-1">
{method.disadvantages.map((dis) => (
<li key={dis} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />{dis}
</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{responsibilities.map((r) => (
<div key={r.role} className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{r.role}</h4>
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.tolerancesTitle')}</h3>
<div className="bg-white p-4 rounded-lg border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">{t('vgm.toleranceLabel')} :</span>
<p className="text-gray-600">{t('vgm.toleranceValue')}</p>
</div>
<div>
<span className="font-medium">{t('vgm.consequenceLabel')} :</span>
<p className="text-gray-600">{t('vgm.consequenceValue')}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.sanctionsTitle')}</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
{sanctions.map((s) => (
<div key={s.region} className="flex items-center justify-between py-3 border-b last:border-0">
<span className="font-medium text-gray-900">{s.region}</span>
<span className="text-sm text-red-600">{s.sanction}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<Card className="mt-8 bg-amber-50 border-amber-200">
<CardContent className="pt-6">
<h3 className="font-semibold text-amber-900 mb-3">{t('vgm.tipsTitle')}</h3>
<ul className="list-disc list-inside space-y-2 text-amber-800">
{tips.map((tip, i) => <li key={i}>{tip}</li>)}
</ul>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,24 +0,0 @@
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import { LandingHeader } from '@/components/layout/LandingHeader';
export async function generateMetadata({
params,
}: {
params: { locale: string };
}): Promise<Metadata> {
const t = await getTranslations({ locale: params.locale, namespace: 'marketing.docs' });
return {
title: t('metadataTitle'),
description: t('metadataDescription'),
};
}
export default function DocsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<LandingHeader />
<main className="pt-20">{children}</main>
</>
);
}

View File

@ -1,92 +0,0 @@
import type { Metadata } from 'next';
import { NextIntlClientProvider, hasLocale } from 'next-intl';
import { getMessages, getTranslations, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import '../globals.css';
import { manrope, montserrat } from '@/lib/fonts';
import { Providers } from '@/components/providers';
import { routing } from '@/i18n/routing';
type Params = { locale: string };
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'metadata.home' });
return {
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || 'https://xpeditis.com'),
title: {
default: t('title'),
template: `%s | ${t('title').split(' — ')[0] ?? 'Xpeditis'}`,
},
description: t('description'),
icons: {
icon: '/assets/logos/logo-black.svg',
shortcut: '/assets/logos/logo-black.svg',
apple: '/assets/logos/logo-black.svg',
},
manifest: '/manifest.json',
openGraph: {
type: 'website',
locale: locale === 'fr' ? 'fr_FR' : 'en_US',
url: 'https://xpeditis.com',
siteName: 'Xpeditis',
title: t('title'),
description: t('description'),
images: [
{
url: '/assets/logos/logo-black.svg',
width: 1875,
height: 1699,
alt: 'Xpeditis Logo',
},
],
},
twitter: {
card: 'summary_large_image',
title: t('title'),
description: t('description'),
images: ['/assets/logos/logo-black.svg'],
},
alternates: {
languages: {
fr: '/fr',
en: '/en',
},
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<Params>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<html lang={locale} className={`${manrope.variable} ${montserrat.variable}`}>
<body className="font-body">
<NextIntlClientProvider locale={locale} messages={messages}>
<Providers>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@ -1,170 +0,0 @@
'use client';
import { useRef } from 'react';
import { motion, useInView } from 'framer-motion';
import { useTranslations } from 'next-intl';
import { FileText, Users, CreditCard, AlertTriangle, Scale, Gavel, Mail, type LucideIcon } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout';
const SECTION_KEYS = ['purpose', 'services', 'account', 'pricing', 'liability', 'ip', 'law'] as const;
const ICONS: Record<typeof SECTION_KEYS[number], LucideIcon> = {
purpose: Users,
services: FileText,
account: Users,
pricing: CreditCard,
liability: AlertTriangle,
ip: Scale,
law: Gavel,
};
function renderInlineBold(content: string) {
return content.split('**').map((part, i) =>
i % 2 === 1 ? <strong key={i}>{part}</strong> : part
);
}
export default function TermsPage() {
const t = useTranslations('marketing.terms');
const tCommon = useTranslations('marketing.common');
const heroRef = useRef(null);
const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { 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 (
<div className="min-h-screen bg-white">
<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">
<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" />
</div>
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8 }}
className="text-center"
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
transition={{ duration: 0.6, delay: 0.2 }}
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
>
<FileText className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span>
</motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')}
<br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')}
</span>
</h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
{t('intro')}
</p>
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</p>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
fill="white"
/>
</svg>
</div>
</section>
{/* Content Section */}
<section ref={contentRef} className="py-20">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
variants={containerVariants}
initial="hidden"
animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-12"
>
{SECTION_KEYS.map((key) => {
const IconComponent = ICONS[key];
return (
<motion.div
key={key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
<IconComponent className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<h2 className="text-2xl font-bold text-brand-navy mb-4">
{t(`sections.${key}.title`)}
</h2>
<div className="text-gray-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
{renderInlineBold(t(`sections.${key}.content`))}
</div>
</div>
</div>
</motion.div>
);
})}
</motion.div>
{/* Contact Section */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.6 }}
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
>
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4">{t('contact.title')}</h3>
<p className="text-white/80 mb-6">{t('contact.body')}</p>
<a
href="mailto:legal@xpeditis.com"
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-colors font-medium"
>
<Mail className="w-5 h-5" />
<span>legal@xpeditis.com</span>
</a>
</motion.div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -1,407 +1,494 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { useTranslations } from 'next-intl'; import Link from 'next/link';
import { Link } from '@/i18n/navigation'; import { motion, useInView } from 'framer-motion';
import { motion, useInView } from 'framer-motion'; import {
import { Ship,
Ship, Target,
Target, Eye,
Eye, Heart,
Heart, Users,
Users, TrendingUp,
TrendingUp, Linkedin,
Linkedin, Calendar,
Calendar, ArrowRight,
ArrowRight, } from 'lucide-react';
type LucideIcon, import { LandingHeader, LandingFooter } from '@/components/layout';
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; export default function AboutPage() {
const heroRef = useRef(null);
type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation'; const missionRef = useRef(null);
type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct'; const valuesRef = useRef(null);
type TimelineKey = '2023' | '2024' | '2025' | '2026'; const teamRef = useRef(null);
type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings'; const timelineRef = useRef(null);
const statsRef = useRef(null);
const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [
{ key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' }, const isHeroInView = useInView(heroRef, { once: true });
{ key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' }, const isMissionInView = useInView(missionRef, { once: true });
{ key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' }, const isValuesInView = useInView(valuesRef, { once: true });
{ key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' }, const isTeamInView = useInView(teamRef, { once: true });
]; const isTimelineInView = useInView(timelineRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true });
const TEAM: { key: TeamKey; name: string; linkedin: string }[] = [
{ key: 'ceo', name: 'Jean-Pierre Durand', linkedin: '#' }, const values = [
{ key: 'cto', name: 'Marie Lefebvre', linkedin: '#' }, {
{ key: 'coo', name: 'Thomas Martin', linkedin: '#' }, icon: Target,
{ key: 'vpSales', name: 'Sophie Bernard', linkedin: '#' }, title: 'Excellence',
{ key: 'vpEng', name: 'Alexandre Petit', linkedin: '#' }, description:
{ key: 'vpProduct', name: 'Claire Moreau', linkedin: '#' }, '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',
},
const TIMELINE_YEARS: TimelineKey[] = ['2023', '2024', '2025', '2026']; {
icon: Heart,
const STATS: { key: StatKey; value: string }[] = [ title: 'Transparence',
{ key: 'clients', value: '500+' }, description:
{ key: 'carriers', value: '50+' }, 'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
{ key: 'countries', value: '15' }, color: 'from-pink-500 to-rose-500',
{ key: 'bookings', value: '100K+' }, },
]; {
icon: Users,
export default function AboutPage() { title: 'Collaboration',
const t = useTranslations('marketing.about'); description:
const heroRef = useRef(null); 'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
const missionRef = useRef(null); color: 'from-purple-500 to-indigo-500',
const valuesRef = useRef(null); },
const teamRef = useRef(null); {
const timelineRef = useRef(null); icon: TrendingUp,
const statsRef = useRef(null); title: 'Innovation',
description:
const isHeroInView = useInView(heroRef, { once: true }); 'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
const isMissionInView = useInView(missionRef, { once: true }); color: 'from-orange-500 to-amber-500',
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 team = [
{
const containerVariants = { name: 'Jean-Pierre Durand',
hidden: { opacity: 0, y: 50 }, role: 'CEO & Co-fondateur',
visible: { bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
opacity: 1, image: '/assets/images/team/ceo.jpg',
y: 0, linkedin: '#',
transition: { },
duration: 0.6, {
staggerChildren: 0.1, 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: '#',
const itemVariants = { },
hidden: { opacity: 0, y: 20 }, {
visible: { name: 'Thomas Martin',
opacity: 1, role: 'COO',
y: 0, bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
transition: { duration: 0.5 }, image: '/assets/images/team/coo.jpg',
}, linkedin: '#',
}; },
{
return ( name: 'Sophie Bernard',
<div className="min-h-screen bg-white"> role: 'VP Sales',
<LandingHeader activePage="about" /> bio: '15 ans d\'expérience commerciale dans le secteur logistique',
image: '/assets/images/team/vp-sales.jpg',
{/* Hero Section */} linkedin: '#',
<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" /> name: 'Alexandre Petit',
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> role: 'VP Engineering',
</div> bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
image: '/assets/images/team/vp-eng.jpg',
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8"> linkedin: '#',
<motion.div },
initial={{ opacity: 0, y: 30 }} {
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} name: 'Claire Moreau',
transition={{ duration: 0.8 }} role: 'VP Product',
className="text-center" bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
> image: '/assets/images/team/vp-product.jpg',
<motion.div linkedin: '#',
initial={{ scale: 0.8, opacity: 0 }} },
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}} ];
transition={{ duration: 0.6, delay: 0.2 }}
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20" const timeline = [
> {
<Ship className="w-5 h-5 text-brand-turquoise" /> year: '2021',
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> title: 'Fondation',
</motion.div> description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
},
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> {
{t('title1')} year: '2022',
<br /> title: 'Première version',
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
{t('title2')} },
</span> {
</h1> year: '2023',
title: 'Série A',
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
{t('intro')} },
</p> {
</motion.div> year: '2024',
</div> title: 'Expansion',
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
{/* Wave */} },
<div className="absolute bottom-0 left-0 right-0"> {
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> year: '2025',
<path title: 'Leader européen',
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z" description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
fill="white" },
/> ];
</svg>
</div> const stats = [
</section> { value: '500+', label: 'Clients actifs' },
{ value: '50+', label: 'Compagnies maritimes' },
{/* Mission & Vision Section */} { value: '15', label: 'Pays couverts' },
<section ref={missionRef} className="py-20"> { value: '100K+', label: 'Réservations/an' },
<div className="max-w-7xl mx-auto px-6 lg:px-8"> ];
<motion.div
variants={containerVariants} const containerVariants = {
initial="hidden" hidden: { opacity: 0, y: 50 },
animate={isMissionInView ? 'visible' : 'hidden'} visible: {
className="grid grid-cols-1 lg:grid-cols-2 gap-12" opacity: 1,
> y: 0,
<motion.div transition: {
variants={itemVariants} duration: 0.6,
className="bg-gradient-to-br from-brand-turquoise/10 to-brand-turquoise/5 p-10 rounded-3xl border border-brand-turquoise/20" staggerChildren: 0.1,
> },
<div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6"> },
<Target className="w-8 h-8 text-white" /> };
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2> const itemVariants = {
<p className="text-gray-600 text-lg leading-relaxed"> hidden: { opacity: 0, y: 20 },
{t('mission.body')} visible: {
</p> opacity: 1,
</motion.div> y: 0,
transition: { duration: 0.5 },
<motion.div },
variants={itemVariants} };
className="bg-gradient-to-br from-brand-green/10 to-brand-green/5 p-10 rounded-3xl border border-brand-green/20"
> return (
<div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6"> <div className="min-h-screen bg-white">
<Eye className="w-8 h-8 text-white" /> <LandingHeader activePage="about" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2> {/* Hero Section */}
<p className="text-gray-600 text-lg leading-relaxed"> <section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
{t('vision.body')} <div className="absolute inset-0 opacity-10">
</p> <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
</motion.div> <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
</motion.div> </div>
</div>
</section> <div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
{/* Stats Section */} initial={{ opacity: 0, y: 30 }}
<section ref={statsRef} className="py-16 bg-gray-50"> animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
<motion.div transition={{ duration: 0.8 }}
variants={containerVariants} className="text-center"
initial="hidden" >
animate={isStatsInView ? 'visible' : 'hidden'} <motion.div
className="max-w-7xl mx-auto px-6 lg:px-8" initial={{ scale: 0.8, opacity: 0 }}
> animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> transition={{ duration: 0.6, delay: 0.2 }}
{STATS.map((stat, index) => ( className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
<motion.div >
key={stat.key} <Ship className="w-5 h-5 text-brand-turquoise" />
variants={itemVariants} <span className="text-white/90 text-sm font-medium">Notre histoire</span>
className="text-center" </motion.div>
>
<motion.div <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
initial={{ scale: 0 }} Révolutionner le fret maritime,
animate={isStatsInView ? { scale: 1 } : {}} <br />
transition={{ duration: 0.5, delay: index * 0.1 }} <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
className="text-5xl lg:text-6xl font-bold text-brand-turquoise mb-2" une réservation à la fois
> </span>
{stat.value} </h1>
</motion.div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
</motion.div> 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
</div> grandes compagnies maritimes.
</motion.div> </p>
</section> </motion.div>
</div>
{/* Values Section */}
<section ref={valuesRef} className="py-20"> {/* Wave */}
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="absolute bottom-0 left-0 right-0">
<motion.div <svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
initial={{ opacity: 0, y: 30 }} <path
animate={isValuesInView ? { opacity: 1, y: 0 } : {}} d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
transition={{ duration: 0.8 }} fill="white"
className="text-center mb-16" />
> </svg>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2> </div>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> </section>
{t('valuesSubtitle')}
</p> {/* Mission & Vision Section */}
</motion.div> <section ref={missionRef} className="py-20">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate={isValuesInView ? 'visible' : 'hidden'} animate={isMissionInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 lg:grid-cols-2 gap-12"
> >
{VALUES.map((value) => { <motion.div
const IconComponent = value.icon; variants={itemVariants}
return ( className="bg-gradient-to-br from-brand-turquoise/10 to-brand-turquoise/5 p-10 rounded-3xl border border-brand-turquoise/20"
<motion.div >
key={value.key} <div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6">
variants={itemVariants} <Target className="w-8 h-8 text-white" />
whileHover={{ y: -10 }} </div>
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all" <h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Mission</h2>
> <p className="text-gray-600 text-lg leading-relaxed">
<div Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${value.color} flex items-center justify-center mb-4`} qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
> tous les professionnels de la logistique.
<IconComponent className="w-7 h-7 text-white" /> </p>
</div> </motion.div>
<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
</motion.div> variants={itemVariants}
); className="bg-gradient-to-br from-brand-green/10 to-brand-green/5 p-10 rounded-3xl border border-brand-green/20"
})} >
</motion.div> <div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6">
</div> <Eye className="w-8 h-8 text-white" />
</section> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Vision</h2>
{/* Timeline Section */} <p className="text-gray-600 text-lg leading-relaxed">
<section ref={timelineRef} className="py-20 bg-gradient-to-br from-gray-50 to-white"> Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire
<div className="max-w-7xl mx-auto px-6 lg:px-8"> à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
<motion.div que mérite le commerce international.
initial={{ opacity: 0, y: 30 }} </p>
animate={isTimelineInView ? { opacity: 1, y: 0 } : {}} </motion.div>
transition={{ duration: 0.8 }} </motion.div>
className="text-center mb-16" </div>
> </section>
<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"> {/* Stats Section */}
{t('timelineSubtitle')} <section ref={statsRef} className="py-16 bg-gray-50">
</p> <motion.div
</motion.div> variants={containerVariants}
initial="hidden"
<div className="relative"> animate={isStatsInView ? 'visible' : 'hidden'}
<div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden"> className="max-w-7xl mx-auto px-6 lg:px-8"
<motion.div >
initial={{ scaleY: 0 }} <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
animate={isTimelineInView ? { scaleY: 1 } : {}} {stats.map((stat, index) => (
transition={{ duration: 2.2, delay: 0.2, ease: 'easeInOut' }} <motion.div
style={{ transformOrigin: 'top' }} key={index}
className="absolute inset-0 bg-brand-turquoise/60" variants={itemVariants}
/> className="text-center"
</div> >
<motion.div
<div className="space-y-12"> initial={{ scale: 0 }}
{TIMELINE_YEARS.map((year, index) => ( animate={isStatsInView ? { scale: 1 } : {}}
<motion.div transition={{ duration: 0.5, delay: index * 0.1 }}
key={year} className="text-5xl lg:text-6xl font-bold text-brand-turquoise mb-2"
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }} >
whileInView={{ opacity: 1, x: 0 }} {stat.value}
viewport={{ once: true, amount: 0.4 }} </motion.div>
transition={{ duration: 0.7, ease: 'easeOut' }} <div className="text-gray-600 font-medium">{stat.label}</div>
className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`} </motion.div>
> ))}
<div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}> </div>
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow"> </motion.div>
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}> </section>
<Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{year}</span> {/* Values Section */}
</div> <section ref={valuesRef} className="py-20">
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p> <motion.div
</div> initial={{ opacity: 0, y: 30 }}
</div> animate={isValuesInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8 }}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0"> className="text-center mb-16"
<motion.div >
initial={{ scale: 0 }} <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Nos Valeurs</h2>
whileInView={{ scale: 1 }} <p className="text-xl text-gray-600 max-w-2xl mx-auto">
viewport={{ once: true, amount: 0.6 }} Les principes qui guident chacune de nos décisions
transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }} </p>
className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30" </motion.div>
/>
</div> <motion.div
variants={containerVariants}
<div className="hidden lg:block flex-1" /> initial="hidden"
</motion.div> animate={isValuesInView ? 'visible' : 'hidden'}
))} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
</div> >
</div> {values.map((value, index) => {
</div> const IconComponent = value.icon;
</section> return (
<motion.div
{/* Team Section */} key={index}
<section ref={teamRef} className="py-20" style={{ display: 'none' }}> variants={itemVariants}
<div className="max-w-7xl mx-auto px-6 lg:px-8"> whileHover={{ y: -10 }}
<motion.div className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
initial={{ opacity: 0, y: 30 }} >
animate={isTeamInView ? { opacity: 1, y: 0 } : {}} <div
transition={{ duration: 0.8 }} className={`w-14 h-14 rounded-xl bg-gradient-to-br ${value.color} flex items-center justify-center mb-4`}
className="text-center mb-16" >
> <IconComponent className="w-7 h-7 text-white" />
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2> </div>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
{t('teamSubtitle')} <p className="text-gray-600">{value.description}</p>
</p> </motion.div>
</motion.div> );
})}
<motion.div </motion.div>
variants={containerVariants} </div>
initial="hidden" </section>
animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" {/* Timeline Section */}
> <section ref={timelineRef} className="py-20 bg-gradient-to-br from-gray-50 to-white">
{TEAM.map((member) => ( <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
key={member.key} initial={{ opacity: 0, y: 30 }}
variants={itemVariants} animate={isTimelineInView ? { opacity: 1, y: 0 } : {}}
whileHover={{ y: -10 }} transition={{ duration: 0.8 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group" className="text-center mb-16"
> >
<div className="aspect-[4/3] bg-gradient-to-br from-brand-navy to-brand-navy/80 flex items-center justify-center relative overflow-hidden"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Parcours</h2>
<div className="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
<Users className="w-12 h-12 text-white/80" /> De la startup au leader européen du fret maritime digital
</div> </p>
<div className="absolute inset-0 bg-brand-turquoise/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> </motion.div>
<a
href={member.linkedin} <div className="relative">
className="w-12 h-12 bg-white rounded-full flex items-center justify-center hover:scale-110 transition-transform" {/* Timeline vertical rail + animated fill */}
> <div className="hidden lg:block absolute left-1/2 transform -translate-x-1/2 w-0.5 h-full bg-brand-turquoise/15 overflow-hidden">
<Linkedin className="w-6 h-6 text-brand-navy" /> <motion.div
</a> initial={{ scaleY: 0 }}
</div> animate={isTimelineInView ? { scaleY: 1 } : {}}
</div> transition={{ duration: 2.2, delay: 0.2, ease: 'easeInOut' }}
<div className="p-6"> style={{ transformOrigin: 'top' }}
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3> className="absolute inset-0 bg-brand-turquoise/60"
<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>
</div>
</motion.div> <div className="space-y-12">
))} {timeline.map((item, index) => (
</motion.div> <motion.div
</div> key={index}
</section> initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }}
{/* CTA Section */} viewport={{ once: true, amount: 0.4 }}
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"> transition={{ duration: 0.7, ease: 'easeOut' }}
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center"> className={`flex items-center ${index % 2 === 0 ? 'lg:flex-row' : 'lg:flex-row-reverse'}`}
<motion.div >
initial={{ opacity: 0, y: 30 }} <div className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}>
whileInView={{ opacity: 1, y: 0 }} <div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
viewport={{ once: true }} <div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
transition={{ duration: 0.8 }} <Calendar className="w-5 h-5 text-brand-turquoise" />
> <span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> </div>
{t('cta.title')} <h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
</h2> <p className="text-gray-600">{item.description}</p>
<p className="text-xl text-white/80 mb-10"> </div>
{t('cta.body')} </div>
</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> {/* Animated center dot */}
<Link <div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
href="/register" <motion.div
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2" initial={{ scale: 0 }}
> whileInView={{ scale: 1 }}
<span>{t('cta.createAccount')}</span> viewport={{ once: true, amount: 0.6 }}
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> transition={{ duration: 0.4, delay: 0.15, type: 'spring', stiffness: 320, damping: 18 }}
</Link> className="w-5 h-5 bg-brand-turquoise rounded-full border-4 border-white shadow-lg ring-2 ring-brand-turquoise/30"
<Link />
href="/careers" </div>
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
> <div className="hidden lg:block flex-1" />
{t('cta.viewCareers')} </motion.div>
</Link> ))}
</div> </div>
</motion.div> </div>
</div> </div>
</section> </section>
<LandingFooter /> {/* Team Section */}
</div> <section ref={teamRef} className="py-20">
); <div className="max-w-7xl mx-auto px-6 lg:px-8">
} <motion.div
initial={{ opacity: 0, y: 30 }}
animate={isTeamInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8 }}
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Équipe</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des experts passionnés par le maritime et la technologie
</p>
</motion.div>
<motion.div
variants={containerVariants}
initial="hidden"
animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{team.map((member, index) => (
<motion.div
key={index}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
>
<div className="aspect-[4/3] bg-gradient-to-br from-brand-navy to-brand-navy/80 flex items-center justify-center relative overflow-hidden">
<div className="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
<Users className="w-12 h-12 text-white/80" />
</div>
<div className="absolute inset-0 bg-brand-turquoise/80 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<a
href={member.linkedin}
className="w-12 h-12 bg-white rounded-full flex items-center justify-center hover:scale-110 transition-transform"
>
<Linkedin className="w-6 h-6 text-brand-navy" />
</a>
</div>
</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">{member.role}</p>
<p className="text-gray-600 text-sm">{member.bio}</p>
</div>
</motion.div>
))}
</motion.div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
Rejoignez l'aventure Xpeditis
</h2>
<p className="text-xl text-white/80 mb-10">
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.
</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"
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl font-semibold text-lg flex items-center space-x-2"
>
<span>Créer un compte</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
>
Voir les offres d'emploi
</Link>
</div>
</motion.div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -1,390 +1,473 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl'; import Link from 'next/link';
import { Link } from '@/i18n/navigation'; import { motion, useInView } from 'framer-motion';
import { motion, useInView } from 'framer-motion'; import {
import { Ship,
Ship, BookOpen,
BookOpen, Calendar,
Calendar, Clock,
Clock, User,
User, ArrowRight,
ArrowRight, Search,
Search, TrendingUp,
TrendingUp, Globe,
Globe, FileText,
FileText, Anchor,
Anchor, } from 'lucide-react';
type LucideIcon, import { LandingHeader, LandingFooter } from '@/components/layout';
} from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; export default function BlogPage() {
const [selectedCategory, setSelectedCategory] = useState('all');
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news'; const [searchQuery, setSearchQuery] = useState('');
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
const heroRef = useRef(null);
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [ const articlesRef = useRef(null);
{ key: 'all', icon: BookOpen }, const categoriesRef = useRef(null);
{ key: 'industry', icon: Ship },
{ key: 'technology', icon: TrendingUp }, const isHeroInView = useInView(heroRef, { once: true });
{ key: 'guides', icon: FileText }, const isArticlesInView = useInView(articlesRef, { once: true });
{ key: 'news', icon: Globe }, const isCategoriesInView = useInView(categoriesRef, { once: true });
];
const categories = [
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [ { value: 'all', label: 'Tous les articles', icon: BookOpen },
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] }, { value: 'industry', label: 'Industrie maritime', icon: Ship },
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] }, { value: 'technology', label: 'Technologie', icon: TrendingUp },
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] }, { value: 'guides', label: 'Guides pratiques', icon: FileText },
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] }, { value: 'news', label: 'Actualités', icon: Globe },
{ 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 featuredArticle = {
]; id: 1,
title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique',
export default function BlogPage() { excerpt:
const t = useTranslations('marketing.blog'); 'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.',
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all'); category: 'technology',
const [searchQuery, setSearchQuery] = useState(''); author: 'Marie Lefebvre',
authorRole: 'CTO',
const heroRef = useRef(null); date: '15 janvier 2025',
const articlesRef = useRef(null); readTime: '8 min',
const categoriesRef = useRef(null); image: '/assets/images/blog/featured.jpg',
tags: ['IA', 'Innovation', 'Logistique'],
const isHeroInView = useInView(heroRef, { once: true }); };
const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true }); const articles = [
{
const filteredArticles = ARTICLES.filter((article) => { id: 2,
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; title: 'Guide complet des Incoterms 2020 pour le transport maritime',
const title = t(`articles.${article.key}.title` as any); excerpt:
const excerpt = t(`articles.${article.key}.excerpt` as any); 'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.',
const searchMatch = category: 'guides',
searchQuery === '' || author: 'Thomas Martin',
title.toLowerCase().includes(searchQuery.toLowerCase()) || date: '10 janvier 2025',
excerpt.toLowerCase().includes(searchQuery.toLowerCase()); readTime: '12 min',
return categoryMatch && searchMatch; image: '/assets/images/blog/incoterms.jpg',
}); tags: ['Incoterms', 'Guide', 'Commerce international'],
},
const containerVariants = { {
hidden: { opacity: 0, y: 50 }, id: 3,
visible: { title: 'Comment optimiser vos coûts de transport maritime en 2025',
opacity: 1, excerpt:
y: 0, 'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.',
transition: { category: 'guides',
duration: 0.6, author: 'Sophie Bernard',
staggerChildren: 0.1, date: '8 janvier 2025',
}, readTime: '6 min',
}, image: '/assets/images/blog/costs.jpg',
}; tags: ['Optimisation', 'Coûts', 'Stratégie'],
},
const itemVariants = { {
hidden: { opacity: 0, y: 20 }, id: 4,
visible: { title: 'Les plus grands ports européens : classement 2025',
opacity: 1, excerpt:
y: 0, 'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.',
transition: { duration: 0.5 }, category: 'industry',
}, author: 'Jean-Pierre Durand',
}; date: '5 janvier 2025',
readTime: '10 min',
return ( image: '/assets/images/blog/ports.jpg',
<div className="min-h-screen bg-white"> tags: ['Ports', 'Europe', 'Statistiques'],
<LandingHeader activePage="blog" /> },
{
{/* Hero Section */} id: 5,
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> title: 'Xpeditis lève 15M€ pour accélérer son expansion',
<div className="absolute inset-0 opacity-10"> excerpt:
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> 'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.',
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> category: 'news',
</div> author: 'Jean-Pierre Durand',
date: '3 janvier 2025',
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8"> readTime: '4 min',
<motion.div image: '/assets/images/blog/funding.jpg',
initial={{ opacity: 0, y: 30 }} tags: ['Financement', 'Croissance', 'Xpeditis'],
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} },
transition={{ duration: 0.8 }} {
className="text-center" id: 6,
> title: 'Décarbonation du transport maritime : où en sommes-nous ?',
<motion.div excerpt:
initial={{ scale: 0.8, opacity: 0 }} 'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.',
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}} category: 'industry',
transition={{ duration: 0.6, delay: 0.2 }} author: 'Claire Moreau',
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20" date: '28 décembre 2024',
> readTime: '9 min',
<BookOpen className="w-5 h-5 text-brand-turquoise" /> image: '/assets/images/blog/green.jpg',
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> tags: ['Environnement', 'Décarbonation', 'Durabilité'],
</motion.div> },
{
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> id: 7,
{t('title1')} title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis',
<br /> excerpt:
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> 'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.',
{t('title2')} category: 'technology',
</span> author: 'Alexandre Petit',
</h1> date: '22 décembre 2024',
readTime: '15 min',
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> image: '/assets/images/blog/api.jpg',
{t('intro')} tags: ['API', 'Intégration', 'Technique'],
</p> },
{
{/* Search Bar */} id: 8,
<motion.div title: 'Les documents essentiels pour l\'export maritime',
initial={{ opacity: 0, y: 20 }} excerpt:
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} 'Check-list complète des documents requis pour vos expéditions maritimes internationales.',
transition={{ duration: 0.6, delay: 0.4 }} category: 'guides',
className="max-w-xl mx-auto" author: 'Thomas Martin',
> date: '18 décembre 2024',
<div className="relative"> readTime: '7 min',
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" /> image: '/assets/images/blog/documents.jpg',
<input tags: ['Documents', 'Export', 'Douane'],
type="text" },
placeholder={t('searchPlaceholder')} ];
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} const filteredArticles = articles.filter((article) => {
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" const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
/> const searchMatch =
</div> searchQuery === '' ||
</motion.div> article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
</motion.div> article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
</div> return categoryMatch && searchMatch;
});
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0"> const containerVariants = {
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> hidden: { opacity: 0, y: 50 },
<path visible: {
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z" opacity: 1,
fill="white" y: 0,
/> transition: {
</svg> duration: 0.6,
</div> staggerChildren: 0.1,
</section> },
},
{/* Categories */} };
<section ref={categoriesRef} className="py-8 border-b border-gray-200">
<motion.div const itemVariants = {
initial={{ opacity: 0, y: 20 }} hidden: { opacity: 0, y: 20 },
animate={isCategoriesInView ? { opacity: 1, y: 0 } : {}} visible: {
transition={{ duration: 0.6 }} opacity: 1,
className="max-w-7xl mx-auto px-6 lg:px-8" y: 0,
> transition: { duration: 0.5 },
<div className="flex flex-wrap items-center justify-center gap-4"> },
{CATEGORIES.map((category) => { };
const IconComponent = category.icon;
const isActive = selectedCategory === category.key; return (
return ( <div className="min-h-screen bg-white">
<button <LandingHeader activePage="blog" />
key={category.key}
onClick={() => setSelectedCategory(category.key)} {/* Hero Section */}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${ <section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
isActive <div className="absolute inset-0 opacity-10">
? 'bg-brand-turquoise text-white' <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
: 'bg-gray-100 text-gray-600 hover:bg-gray-200' <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
}`} </div>
>
<IconComponent className="w-4 h-4" /> <div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
<span className="font-medium">{t(`categories.${category.key}`)}</span> <motion.div
</button> initial={{ opacity: 0, y: 30 }}
); animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
})} transition={{ duration: 0.8 }}
</div> className="text-center"
</motion.div> >
</section> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
{/* Featured Article */} animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
<section className="py-16"> transition={{ duration: 0.6, delay: 0.2 }}
<div className="max-w-7xl mx-auto px-6 lg:px-8"> className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
<motion.div >
initial={{ opacity: 0, y: 30 }} <BookOpen className="w-5 h-5 text-brand-turquoise" />
whileInView={{ opacity: 1, y: 0 }} <span className="text-white/90 text-sm font-medium">Blog Xpeditis</span>
viewport={{ once: true }} </motion.div>
transition={{ duration: 0.8 }}
> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
<Link href="/blog/1"> Actualités & Insights
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer"> <br />
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" /> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center"> du fret maritime
<Anchor className="w-48 h-48 text-white/10" /> </span>
</div> </h1>
<div className="relative z-20 p-8 lg:p-12"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
<div className="max-w-2xl"> Restez informé des dernières tendances du transport maritime, découvrez nos guides
<div className="flex items-center space-x-2 mb-4"> pratiques et suivez l'actualité de Xpeditis.
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full"> </p>
{t('featuredBadge')}
</span> {/* Search Bar */}
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full"> <motion.div
{t('categories.technology')} initial={{ opacity: 0, y: 20 }}
</span> animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
</div> transition={{ duration: 0.6, delay: 0.4 }}
className="max-w-xl mx-auto"
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors"> >
{t('featured.title')} <div className="relative">
</h2> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p> type="text"
placeholder="Rechercher un article..."
<div className="flex items-center space-x-6 text-white/60 text-sm"> value={searchQuery}
<div className="flex items-center space-x-2"> onChange={(e) => setSearchQuery(e.target.value)}
<User className="w-4 h-4" /> 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"
<span>{t('featured.author')}</span> />
</div> </div>
<div className="flex items-center space-x-2"> </motion.div>
<Calendar className="w-4 h-4" /> </motion.div>
<span>{t('featured.date')}</span> </div>
</div>
<div className="flex items-center space-x-2"> {/* Wave */}
<Clock className="w-4 h-4" /> <div className="absolute bottom-0 left-0 right-0">
<span>{t('featured.readTime')}</span> <svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
</div> <path
</div> d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
fill="white"
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity"> />
<span>{t('readArticle')}</span> </svg>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> </div>
</div> </section>
</div>
</div> {/* Categories */}
</div> <section ref={categoriesRef} className="py-8 border-b border-gray-200">
</Link> <motion.div
</motion.div> initial={{ opacity: 0, y: 20 }}
</div> animate={isCategoriesInView ? { opacity: 1, y: 0 } : {}}
</section> transition={{ duration: 0.6 }}
className="max-w-7xl mx-auto px-6 lg:px-8"
{/* Articles Grid */} >
<section ref={articlesRef} className="py-16 bg-gray-50"> <div className="flex flex-wrap items-center justify-center gap-4">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> {categories.map((category) => {
<motion.div const IconComponent = category.icon;
initial={{ opacity: 0, y: 30 }} const isActive = selectedCategory === category.value;
animate={isArticlesInView ? { opacity: 1, y: 0 } : {}} return (
transition={{ duration: 0.8 }} <button
className="flex items-center justify-between mb-12" key={category.value}
> onClick={() => setSelectedCategory(category.value)}
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2> className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span> isActive
</motion.div> ? 'bg-brand-turquoise text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
{filteredArticles.length === 0 ? ( }`}
<div className="text-center py-12"> >
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <IconComponent className="w-4 h-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3> <span className="font-medium">{category.label}</span>
<p className="text-gray-500">{t('noResults.body')}</p> </button>
</div> );
) : ( })}
<motion.div </div>
variants={containerVariants} </motion.div>
initial="hidden" </section>
animate={isArticlesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" {/* Featured Article */}
> <section className="py-16">
{filteredArticles.map((article) => ( <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div key={article.id} variants={itemVariants}> <motion.div
<Link href={`/blog/${article.id}`}> initial={{ opacity: 0, y: 30 }}
<div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col"> whileInView={{ opacity: 1, y: 0 }}
<div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative"> viewport={{ once: true }}
<Ship className="w-16 h-16 text-brand-navy/20" /> transition={{ duration: 0.8 }}
<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"> <Link href={`/blog/${featuredArticle.id}`}>
{t(`categories.${article.category}`)} <div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
</span> <div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
</div> <div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
</div> <Anchor className="w-48 h-48 text-white/10" />
</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"> <div className="relative z-20 p-8 lg:p-12">
{t(`articles.${article.key}.title` as any)} <div className="max-w-2xl">
</h3> <div className="flex items-center space-x-2 mb-4">
<span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
<p className="text-gray-600 mb-4 line-clamp-2 flex-1"> À la une
{t(`articles.${article.key}.excerpt` as any)} </span>
</p> <span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{categories.find((c) => c.value === featuredArticle.category)?.label}
<div className="flex flex-wrap gap-2 mb-4"> </span>
{article.tags.map((tag) => ( </div>
<span
key={tag} <h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full" {featuredArticle.title}
> </h2>
{tag}
</span> <p className="text-lg text-white/80 mb-6">{featuredArticle.excerpt}</p>
))}
</div> <div className="flex items-center space-x-6 text-white/60 text-sm">
<div className="flex items-center space-x-2">
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100"> <User className="w-4 h-4" />
<div className="flex items-center space-x-2"> <span>{featuredArticle.author}</span>
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center"> </div>
<User className="w-4 h-4 text-brand-turquoise" /> <div className="flex items-center space-x-2">
</div> <Calendar className="w-4 h-4" />
<span>{t(`articles.${article.key}.author` as any)}</span> <span>{featuredArticle.date}</span>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-2">
<span>{t(`articles.${article.key}.date` as any)}</span> <Clock className="w-4 h-4" />
<span className="flex items-center space-x-1"> <span>{featuredArticle.readTime}</span>
<Clock className="w-4 h-4" /> </div>
<span>{t(`articles.${article.key}.readTime` as any)}</span> </div>
</span>
</div> <div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity">
</div> <span>Lire l'article</span>
</div> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div> </div>
</Link> </div>
</motion.div> </div>
))} </div>
</motion.div> </Link>
)} </motion.div>
</div>
{/* Load More */} </section>
{filteredArticles.length > 0 && (
<motion.div {/* Articles Grid */}
initial={{ opacity: 0 }} <section ref={articlesRef} className="py-16 bg-gray-50">
whileInView={{ opacity: 1 }} <div className="max-w-7xl mx-auto px-6 lg:px-8">
viewport={{ once: true }} <motion.div
transition={{ duration: 0.6, delay: 0.2 }} initial={{ opacity: 0, y: 30 }}
className="text-center mt-12" animate={isArticlesInView ? { opacity: 1, y: 0 } : {}}
> transition={{ duration: 0.8 }}
<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"> className="flex items-center justify-between mb-12"
{t('loadMore')} >
</button> <h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
</motion.div> <span className="text-gray-500">{filteredArticles.length} articles</span>
)} </motion.div>
</div>
</section> {filteredArticles.length === 0 ? (
<div className="text-center py-12">
{/* Newsletter Section */} <Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95"> <h3 className="text-xl font-medium text-gray-600">Aucun article trouvé</h3>
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center"> <p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
<motion.div </div>
initial={{ opacity: 0, y: 30 }} ) : (
whileInView={{ opacity: 1, y: 0 }} <motion.div
viewport={{ once: true }} variants={containerVariants}
transition={{ duration: 0.8 }} initial="hidden"
> animate={isArticlesInView ? 'visible' : 'hidden'}
<h2 className="text-4xl font-bold text-white mb-6"> className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
{t('newsletter.title')} >
</h2> {filteredArticles.map((article) => (
<p className="text-xl text-white/80 mb-10"> <motion.div key={article.id} variants={itemVariants}>
{t('newsletter.body')} <Link href={`/blog/${article.id}`}>
</p> <div className="bg-white rounded-2xl shadow-lg overflow-hidden group hover:shadow-xl transition-all h-full flex flex-col">
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div className="aspect-video bg-gradient-to-br from-brand-navy/10 to-brand-turquoise/10 flex items-center justify-center relative">
<input <Ship className="w-16 h-16 text-brand-navy/20" />
type="email" <div className="absolute top-4 left-4">
placeholder={t('newsletter.emailPlaceholder')} <span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none" {categories.find((c) => c.value === article.category)?.label}
/> </span>
<button </div>
type="submit" </div>
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
> <div className="p-6 flex-1 flex flex-col">
<span>{t('newsletter.subscribe')}</span> <h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
<ArrowRight className="w-5 h-5" /> {article.title}
</button> </h3>
</form>
<p className="text-white/50 text-sm mt-4"> <p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
{t('newsletter.disclaimer')}
</p> <div className="flex flex-wrap gap-2 mb-4">
</motion.div> {article.tags.map((tag) => (
</div> <span
</section> key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
<LandingFooter /> >
</div> {tag}
); </span>
} ))}
</div>
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
<div className="flex items-center space-x-2">
<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>{article.author}</span>
</div>
<div className="flex items-center space-x-4">
<span>{article.date}</span>
<span className="flex items-center space-x-1">
<Clock className="w-4 h-4" />
<span>{article.readTime}</span>
</span>
</div>
</div>
</div>
</div>
</Link>
</motion.div>
))}
</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">
Charger plus d'articles
</button>
</motion.div>
)}
</div>
</section>
{/* Newsletter Section */}
<section className="py-20 bg-gradient-to-br from-brand-navy to-brand-navy/95">
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-white mb-6">
Restez informé
</h2>
<p className="text-xl text-white/80 mb-10">
Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
du fret maritime directement dans votre boîte mail.
</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"
placeholder="votre@email.com"
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/>
<button
type="submit"
className="w-full sm:w-auto px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-semibold flex items-center justify-center space-x-2"
>
<span>S'abonner</span>
<ArrowRight className="w-5 h-5" />
</button>
</form>
<p className="text-white/50 text-sm mt-4">
En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment.
</p>
</motion.div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -8,21 +8,21 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings'; import { acceptCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingConfirmPage() { export default function BookingConfirmPage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const token = params.token as string; const token = params.token as string;
const t = useTranslations('bookingPortal.confirm');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [booking, setBooking] = useState<CsvBookingResponse | null>(null); const [booking, setBooking] = useState<CsvBookingResponse | null>(null);
const [isAccepting, setIsAccepting] = useState(false);
const handleAccept = useCallback(async () => { const handleAccept = useCallback(async () => {
setIsAccepting(true);
setError(null); setError(null);
try { try {
@ -33,29 +33,31 @@ export default function BookingConfirmPage() {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message); setError(err.message);
} else { } else {
setError(t('errorGeneric')); setError('Une erreur est survenue lors de l\'acceptation');
} }
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsAccepting(false);
} }
}, [token, t]); }, [token]);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setError(t('tokenInvalid')); setError('Token de confirmation invalide');
setIsLoading(false); setIsLoading(false);
return; return;
} }
// Auto-accept the booking
handleAccept(); handleAccept();
}, [token, handleAccept, t]); }, [token, handleAccept]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center"> <div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p> <p className="text-gray-600">Confirmation en cours...</p>
</div> </div>
</div> </div>
); );
@ -82,24 +84,24 @@ export default function BookingConfirmPage() {
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')} Erreur de confirmation
</h1> </h1>
<p className="text-gray-600">{error}</p> <p className="text-gray-600">{error}</p>
</div> </div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
<strong>{t('errorReasonsTitle')}</strong> <strong>Raisons possibles :</strong>
</p> </p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside"> <ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>{t('errorReason1')}</li> <li>Le lien a expiré</li>
<li>{t('errorReason2')}</li> <li>La demande a déjà é acceptée ou refusée</li>
<li>{t('errorReason3')}</li> <li>Le token de confirmation est invalide</li>
</ul> </ul>
</div> </div>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">
{t('errorContact')} Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
</p> </p>
</div> </div>
</div> </div>
@ -131,68 +133,67 @@ export default function BookingConfirmPage() {
/> />
</svg> </svg>
</div> </div>
{/* Animated rings */}
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div> <div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-3"> <h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('successTitle')} Demande acceptée !
</h1> </h1>
<p className="text-lg text-gray-600 mb-2"> <p className="text-lg text-gray-600 mb-2">
{t('successHeadline')} Merci d'avoir accepté cette demande de transport.
</p> </p>
<p className="text-gray-500"> <p className="text-gray-500">
{t('successBody')} Le client a é notifié par email.
</p> </p>
</div> </div>
{/* Booking Summary */} {/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6"> <div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> <h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')} Récapitulatif de la réservation
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.bookingId')}</span> <span className="text-gray-600">ID Réservation</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span> <span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.route')}</span> <span className="text-gray-600">Trajet</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{booking.origin} {booking.destination} {booking.origin} {booking.destination}
</span> </span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.volume')}</span> <span className="text-gray-600">Volume</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span> <span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.weight')}</span> <span className="text-gray-600">Poids</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span> <span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.pallets')}</span> <span className="text-gray-600">Palettes</span>
<span className="font-semibold text-gray-900">{booking.palletCount}</span> <span className="font-semibold text-gray-900">{booking.palletCount}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.containerType')}</span> <span className="text-gray-600">Type de conteneur</span>
<span className="font-semibold text-gray-900">{booking.containerType}</span> <span className="font-semibold text-gray-900">{booking.containerType}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.transitDays')}</span> <span className="text-gray-600">Temps de transit</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
{t('transitDaysValue', { count: booking.transitDays })}
</span>
</div> </div>
<div className="flex justify-between py-3"> <div className="flex justify-between py-3">
<span className="text-gray-600 text-lg">{t('labels.price')}</span> <span className="text-gray-600 text-lg">Prix</span>
<div className="text-right"> <div className="text-right">
<div className="font-bold text-xl text-green-600"> <div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD' {booking.primaryCurrency === 'USD'
@ -212,7 +213,7 @@ export default function BookingConfirmPage() {
{booking.notes && ( {booking.notes && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">{t('labels.notes')}</p> <p className="text-sm text-gray-600 mb-1">Notes :</p>
<p className="text-gray-800">{booking.notes}</p> <p className="text-gray-800">{booking.notes}</p>
</div> </div>
)} )}
@ -224,19 +225,19 @@ export default function BookingConfirmPage() {
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
{t('nextStepsTitle')} Prochaines étapes
</h3> </h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside"> <ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>{t('nextStep1')}</li> <li>Le client va finaliser les détails du conteneur</li>
<li>{t('nextStep2')}</li> <li>Vous recevrez un email avec les documents nécessaires</li>
<li>{t('nextStep3')}</li> <li>Le paiement sera traité selon vos conditions habituelles</li>
</ul> </ul>
</div> </div>
{/* Documents Section */} {/* Documents Section */}
{booking.documents && booking.documents.length > 0 && ( {booking.documents && booking.documents.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-6"> <div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3> <h3 className="font-semibold text-gray-900 mb-3">Documents fournis</h3>
<div className="space-y-2"> <div className="space-y-2">
{booking.documents.map((doc, index) => ( {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">
@ -255,7 +256,7 @@ export default function BookingConfirmPage() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium" className="text-blue-600 hover:text-blue-700 text-sm font-medium"
> >
{t('labels.download')} Télécharger
</a> </a>
</div> </div>
))} ))}
@ -265,7 +266,7 @@ export default function BookingConfirmPage() {
{/* Contact Info */} {/* Contact Info */}
<div className="text-center text-sm text-gray-500"> <div className="text-center text-sm text-gray-500">
<p>{tCommon('supportPrompt')}</p> <p>Pour toute question, contactez-nous à</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline"> <a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com support@xpeditis.com
</a> </a>

View File

@ -9,14 +9,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings'; import { rejectCsvBooking, type CsvBookingResponse } from '@/lib/api/bookings';
export default function BookingRejectPage() { export default function BookingRejectPage() {
const params = useParams(); const params = useParams();
const token = params.token as string; const token = params.token as string;
const t = useTranslations('bookingPortal.reject');
const tCommon = useTranslations('bookingPortal.common');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -28,13 +25,14 @@ export default function BookingRejectPage() {
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setError(t('tokenInvalid')); setError('Token de refus invalide');
setIsLoading(false); setIsLoading(false);
return; return;
} }
// Just validate the token exists, don't auto-reject
setIsLoading(false); setIsLoading(false);
}, [token, t]); }, [token]);
const handleReject = async () => { const handleReject = async () => {
if (!token) return; if (!token) return;
@ -51,7 +49,7 @@ export default function BookingRejectPage() {
if (err instanceof Error) { if (err instanceof Error) {
setError(err.message); setError(err.message);
} else { } else {
setError(t('errorGeneric')); setError('Une erreur est survenue lors du refus');
} }
} finally { } finally {
setIsRejecting(false); setIsRejecting(false);
@ -63,7 +61,7 @@ export default function BookingRejectPage() {
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center"> <div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p> <p className="text-gray-600">Chargement...</p>
</div> </div>
</div> </div>
); );
@ -90,34 +88,36 @@ export default function BookingRejectPage() {
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('errorTitle')} Erreur de refus
</h1> </h1>
<p className="text-gray-600">{error}</p> <p className="text-gray-600">{error}</p>
</div> </div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
<strong>{t('errorReasonsTitle')}</strong> <strong>Raisons possibles :</strong>
</p> </p>
<ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside"> <ul className="text-sm text-red-700 mt-2 space-y-1 list-disc list-inside">
<li>{t('errorReason1')}</li> <li>Le lien a expiré</li>
<li>{t('errorReason2')}</li> <li>La demande a déjà é acceptée ou refusée</li>
<li>{t('errorReason3')}</li> <li>Le token est invalide</li>
</ul> </ul>
</div> </div>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-500 text-center">
{t('errorContact')} Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le client directement.
</p> </p>
</div> </div>
</div> </div>
); );
} }
// After successful rejection
if (hasRejected && booking) { if (hasRejected && booking) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full"> <div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full">
{/* Rejection Icon with Animation */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="relative inline-block"> <div className="relative inline-block">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-scale-in"> <div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-scale-in">
@ -138,46 +138,47 @@ export default function BookingRejectPage() {
</div> </div>
<h1 className="text-3xl font-bold text-gray-900 mb-3"> <h1 className="text-3xl font-bold text-gray-900 mb-3">
{t('rejectedTitle')} Demande refusée
</h1> </h1>
<p className="text-lg text-gray-600 mb-2"> <p className="text-lg text-gray-600 mb-2">
{t('rejectedHeadline')} Vous avez refusé cette demande de transport.
</p> </p>
<p className="text-gray-500"> <p className="text-gray-500">
{t('rejectedBody')} Le client a é notifié par email.
</p> </p>
</div> </div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6"> <div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"> <h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('summaryTitle')} Récapitulatif de la demande refusée
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.bookingId')}</span> <span className="text-gray-600">ID Réservation</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span> <span className="font-semibold text-gray-900">{booking.bookingId}</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.route')}</span> <span className="text-gray-600">Trajet</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{booking.origin} {booking.destination} {booking.origin} {booking.destination}
</span> </span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.volume')}</span> <span className="text-gray-600">Volume</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span> <span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
</div> </div>
<div className="flex justify-between py-2 border-b border-gray-200"> <div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.weight')}</span> <span className="text-gray-600">Poids</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span> <span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
</div> </div>
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<span className="text-gray-600">{t('labels.proposedPrice')}</span> <span className="text-gray-600">Prix proposé</span>
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{booking.primaryCurrency === 'USD' {booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}` ? `$${booking.priceUSD.toLocaleString()}`
@ -189,7 +190,7 @@ export default function BookingRejectPage() {
{reason && ( {reason && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">{t('labels.rejectionReason')}</p> <p className="text-sm text-gray-600 mb-1">Raison du refus :</p>
<p className="text-gray-800 bg-white p-3 rounded border border-gray-200"> <p className="text-gray-800 bg-white p-3 rounded border border-gray-200">
{reason} {reason}
</p> </p>
@ -197,20 +198,22 @@ export default function BookingRejectPage() {
)} )}
</div> </div>
{/* Info Message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6"> <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"> <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"> <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> </svg>
{t('infoTitle')} Information
</h3> </h3>
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800">
{t('infoBody')} Le client pourra soumettre une nouvelle demande avec des conditions différentes si nécessaire.
</p> </p>
</div> </div>
{/* Contact Info */}
<div className="text-center text-sm text-gray-500"> <div className="text-center text-sm text-gray-500">
<p>{tCommon('supportPrompt')}</p> <p>Pour toute question, contactez-nous à</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline"> <a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
support@xpeditis.com support@xpeditis.com
</a> </a>
@ -240,9 +243,11 @@ export default function BookingRejectPage() {
); );
} }
// Initial rejection form
return ( return (
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full"> <div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full">
{/* Warning Icon */}
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg <svg
@ -260,13 +265,14 @@ export default function BookingRejectPage() {
</svg> </svg>
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2"> <h1 className="text-2xl font-bold text-gray-900 mb-2">
{t('formTitle')} Refuser cette demande
</h1> </h1>
<p className="text-gray-600"> <p className="text-gray-600">
{t('formIntro')} Vous êtes sur le point de refuser cette demande de transport.
</p> </p>
</div> </div>
{/* Optional Reason Field */}
<div className="mb-6"> <div className="mb-6">
{!showReasonField ? ( {!showReasonField ? (
<button <button
@ -274,7 +280,7 @@ export default function BookingRejectPage() {
className="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors" className="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-gray-700">{t('addReason')}</span> <span className="text-gray-700">Ajouter une raison (optionnel)</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg> </svg>
@ -283,20 +289,20 @@ export default function BookingRejectPage() {
) : ( ) : (
<div> <div>
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
{t('reasonLabel')} Raison du refus (optionnel)
</label> </label>
<textarea <textarea
id="reason" id="reason"
rows={4} rows={4}
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
placeholder={t('reasonPlaceholder')} placeholder="Ex: Prix trop élevé, délais trop courts, itinéraire non disponible..."
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" 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} maxLength={500}
/> />
<div className="mt-1 flex items-center justify-between"> <div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{t('reasonHint')} Cette information sera communiquée au client
</p> </p>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{reason.length}/500 {reason.length}/500
@ -306,12 +312,14 @@ export default function BookingRejectPage() {
)} )}
</div> </div>
{/* Warning Message */}
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6"> <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-6">
<p className="text-sm text-orange-800"> <p className="text-sm text-orange-800">
<strong>{t('warningTitle')}</strong> {t('warningBody')} <strong>Attention :</strong> Cette action est irréversible. Le client sera immédiatement notifié par email de votre refus.
</p> </p>
</div> </div>
{/* Action Buttons */}
<div className="space-y-3"> <div className="space-y-3">
<button <button
onClick={handleReject} onClick={handleReject}
@ -324,14 +332,14 @@ export default function BookingRejectPage() {
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <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> <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> </svg>
{t('submitting')} Refus en cours...
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> </svg>
{t('submit')} Confirmer le refus
</> </>
)} )}
</button> </button>
@ -340,12 +348,13 @@ export default function BookingRejectPage() {
href="mailto:support@xpeditis.com" href="mailto:support@xpeditis.com"
className="block w-full px-6 py-3 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 font-semibold rounded-lg transition-colors text-center" className="block w-full px-6 py-3 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 font-semibold rounded-lg transition-colors text-center"
> >
{tCommon('contactSupport')} Contacter le support
</a> </a>
</div> </div>
{/* Help Text */}
<p className="mt-6 text-xs text-center text-gray-500"> <p className="mt-6 text-xs text-center text-gray-500">
{t('helpText')} Si vous avez des questions avant de refuser, contactez-nous par email.
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,153 +1,153 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useRouter } from '@/i18n/navigation'; import { CheckCircle, Loader2, XCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { CheckCircle, Loader2, XCircle } from 'lucide-react'; export default function CarrierAcceptPage() {
const params = useParams();
export default function CarrierAcceptPage() { const router = useRouter();
const params = useParams(); const token = params.token as string;
const router = useRouter();
const token = params.token as string; const [loading, setLoading] = useState(true);
const t = useTranslations('carrierPortal'); const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(5);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); // Prevent double API calls (React 18 StrictMode issue)
const [countdown, setCountdown] = useState(5); const hasCalledApi = useRef(false);
// Prevent double API calls (React 18 StrictMode issue) useEffect(() => {
const hasCalledApi = useRef(false); const acceptBooking = async () => {
// Protection contre les doubles appels
useEffect(() => { if (hasCalledApi.current) {
const acceptBooking = async () => { return;
if (hasCalledApi.current) { }
return; hasCalledApi.current = true;
}
hasCalledApi.current = true; if (!token) {
setError('Token manquant');
if (!token) { setLoading(false);
setError(t('common.tokenMissing')); return;
setLoading(false); }
return;
} try {
// Appeler l'API backend pour accepter le booking
try { const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/accept/${token}`, { method: 'GET',
method: 'GET', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', },
}, });
});
if (!response.ok) {
if (!response.ok) { let errorData;
let errorData; try {
try { errorData = await response.json();
errorData = await response.json(); } catch (e) {
} catch (e) { errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` }; }
}
let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
let errorMessage = errorData.message || t('accept.errorFallback');
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) {
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) { errorMessage = 'Ce booking a déjà été accepté.';
errorMessage = t('common.bookingAlreadyAccepted'); } else if (errorMessage.includes('status REJECTED')) {
} else if (errorMessage.includes('status REJECTED')) { errorMessage = 'Ce booking a déjà été refusé.';
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 = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound'); }
}
throw new Error(errorMessage);
throw new Error(errorMessage); }
}
setLoading(false);
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown((prev) => { setCountdown((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
router.push('/'); router.push('/');
return 0; return 0;
} }
return prev - 1; return prev - 1;
}); });
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
} catch (err) { } catch (err) {
console.error('Error accepting booking:', err); console.error('Error accepting booking:', err);
setError(err instanceof Error ? err.message : t('accept.errorGeneric')); setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation');
setLoading(false); setLoading(false);
} }
}; };
acceptBooking(); acceptBooking();
}, [token, router, t]); }, [token, router]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50"> <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"> <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" /> <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"> <h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('accept.loadingTitle')} Traitement en cours...
</h1> </h1>
<p className="text-gray-600"> <p className="text-gray-600">
{t('accept.loadingMessage')} Nous traitons votre acceptation.
</p> </p>
</div> </div>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <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"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<p className="text-gray-600 mb-6">{error}</p> <p className="text-gray-600 mb-6">{error}</p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
> >
{t('common.backHome')} Retour à l'accueil
</button> </button>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50"> <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"> <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" /> <CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4"> <h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('accept.thanksTitle')} Merci !
</h1> </h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6"> <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"> <p className="text-green-800 font-medium text-lg mb-2">
{t('accept.successHeadline')} Votre acceptation a bien é prise en compte
</p> </p>
<p className="text-green-700 text-sm"> <p className="text-green-700 text-sm">
{t('accept.successBody')} Nous vous remercions d'avoir accepté cette demande de transport.
</p> </p>
</div> </div>
<p className="text-gray-500 text-sm mb-4"> <p className="text-gray-500 text-sm mb-4">
{t('common.redirecting', { countdown })} Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
</p> </p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors" className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors"
> >
{t('common.backHome')} Retour à l'accueil
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,153 +1,153 @@
'use client'; 'use client';
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useRouter } from '@/i18n/navigation'; import { XCircle, Loader2, CheckCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { XCircle, Loader2, CheckCircle } from 'lucide-react'; export default function CarrierRejectPage() {
const params = useParams();
export default function CarrierRejectPage() { const router = useRouter();
const params = useParams(); const token = params.token as string;
const router = useRouter();
const token = params.token as string; const [loading, setLoading] = useState(true);
const t = useTranslations('carrierPortal'); const [error, setError] = useState<string | null>(null);
const [countdown, setCountdown] = useState(5);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); // Prevent double API calls (React 18 StrictMode issue)
const [countdown, setCountdown] = useState(5); const hasCalledApi = useRef(false);
// Prevent double API calls (React 18 StrictMode issue) useEffect(() => {
const hasCalledApi = useRef(false); const rejectBooking = async () => {
// Protection contre les doubles appels
useEffect(() => { if (hasCalledApi.current) {
const rejectBooking = async () => { return;
if (hasCalledApi.current) { }
return; hasCalledApi.current = true;
}
hasCalledApi.current = true; if (!token) {
setError('Token manquant');
if (!token) { setLoading(false);
setError(t('common.tokenMissing')); return;
setLoading(false); }
return;
} try {
// Appeler l'API backend pour refuser le booking
try { const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/reject/${token}`, { method: 'GET',
method: 'GET', headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', },
}, });
});
if (!response.ok) {
if (!response.ok) { let errorData;
let errorData; try {
try { errorData = await response.json();
errorData = await response.json(); } catch (e) {
} catch (e) { errorData = { message: `Erreur HTTP ${response.status}` };
errorData = { message: `HTTP ${response.status}` }; }
}
let errorMessage = errorData.message || 'Erreur lors du refus du booking';
let errorMessage = errorData.message || t('reject.errorFallback');
if (errorMessage.includes('status REJECTED')) {
if (errorMessage.includes('status REJECTED')) { errorMessage = 'Ce booking a déjà été refusé.';
errorMessage = t('common.bookingAlreadyRejected'); } else if (errorMessage.includes('status ACCEPTED')) {
} else if (errorMessage.includes('status ACCEPTED')) { errorMessage = 'Ce booking a déjà été accepté.';
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 = 'Booking introuvable. Le lien peut avoir expiré.';
errorMessage = t('common.bookingNotFound'); }
}
throw new Error(errorMessage);
throw new Error(errorMessage); }
}
setLoading(false);
setLoading(false);
// Démarrer le compte à rebours
const timer = setInterval(() => { const timer = setInterval(() => {
setCountdown((prev) => { setCountdown((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer); clearInterval(timer);
router.push('/'); router.push('/');
return 0; return 0;
} }
return prev - 1; return prev - 1;
}); });
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
} catch (err) { } catch (err) {
console.error('Error rejecting booking:', err); console.error('Error rejecting booking:', err);
setError(err instanceof Error ? err.message : t('reject.errorGeneric')); setError(err instanceof Error ? err.message : 'Erreur lors du refus');
setLoading(false); setLoading(false);
} }
}; };
rejectBooking(); rejectBooking();
}, [token, router, t]); }, [token, router]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <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"> <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" /> <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"> <h1 className="text-2xl font-bold text-gray-900 mb-4">
{t('reject.loadingTitle')} Traitement en cours...
</h1> </h1>
<p className="text-gray-600"> <p className="text-gray-600">
{t('reject.loadingMessage')} Nous traitons votre refus.
</p> </p>
</div> </div>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <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"> <div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
<p className="text-gray-600 mb-6">{error}</p> <p className="text-gray-600 mb-6">{error}</p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
> >
{t('common.backHome')} Retour à l'accueil
</button> </button>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50"> <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"> <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" /> <CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4"> <h1 className="text-3xl font-bold text-gray-900 mb-4">
{t('reject.thanksTitle')} Merci de votre réponse
</h1> </h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6"> <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"> <p className="text-orange-800 font-medium text-lg mb-2">
{t('reject.successHeadline')} Votre refus a bien é pris en compte
</p> </p>
<p className="text-orange-700 text-sm"> <p className="text-orange-700 text-sm">
{t('reject.successBody')} Nous avons bien enregistré votre décision concernant cette demande de transport.
</p> </p>
</div> </div>
<p className="text-gray-500 text-sm mb-4"> <p className="text-gray-500 text-sm mb-4">
{t('common.redirecting', { countdown })} Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
</p> </p>
<button <button
onClick={() => router.push('/')} onClick={() => router.push('/')}
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors" className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors"
> >
{t('common.backHome')} Retour à l'accueil
</button> </button>
</div> </div>
</div> </div>
); );
} }

View File

@ -1,368 +1,429 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { useTranslations } from 'next-intl'; import { motion, useInView } from 'framer-motion';
import { motion, useInView } from 'framer-motion'; import {
import { Shield,
Shield, UserCheck,
UserCheck, Database,
Database, FileText,
FileText, Globe,
Globe, Clock,
Clock, CheckCircle,
CheckCircle, Download,
Download, Trash2,
Trash2, Edit,
Edit, Eye,
Eye, Mail,
Mail, } from 'lucide-react';
type LucideIcon, import Link from 'next/link';
} from 'lucide-react'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout'; export default function CompliancePage() {
const heroRef = useRef(null);
type RightKey = 'access' | 'rectification' | 'erasure' | 'portability'; const contentRef = useRef(null);
type PrincipleKey = 'minimization' | 'retention' | 'integrity' | 'transparency';
type MeasureKey = 'technical' | 'organizational'; const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true });
const RIGHTS: { key: RightKey; icon: LucideIcon }[] = [
{ key: 'access', icon: Eye }, const rights = [
{ key: 'rectification', icon: Edit }, {
{ key: 'erasure', icon: Trash2 }, icon: Eye,
{ key: 'portability', icon: Download }, title: 'Droit d\'accès',
]; description: 'Obtenez une copie de toutes les données personnelles que nous détenons sur vous.',
},
const PRINCIPLES: { key: PrincipleKey; icon: LucideIcon }[] = [ {
{ key: 'minimization', icon: Database }, icon: Edit,
{ key: 'retention', icon: Clock }, title: 'Droit de rectification',
{ key: 'integrity', icon: Shield }, description: 'Faites corriger vos données personnelles si elles sont inexactes ou incomplètes.',
{ key: 'transparency', icon: FileText }, },
]; {
icon: Trash2,
const MEASURES: MeasureKey[] = ['technical', 'organizational']; title: 'Droit à l\'effacement',
const MEASURE_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const; description: 'Demandez la suppression de vos données personnelles ("droit à l\'oubli").',
const REGISTER_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const; },
{
export default function CompliancePage() { icon: Download,
const t = useTranslations('marketing.compliance'); title: 'Droit à la portabilité',
const heroRef = useRef(null); description: 'Recevez vos données dans un format structuré, lisible par machine.',
const contentRef = useRef(null); },
];
const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true }); const principles = [
{
const containerVariants = { icon: Database,
hidden: { opacity: 0, y: 50 }, title: 'Minimisation des données',
visible: { description: 'Nous ne collectons que les données strictement nécessaires à nos services.',
opacity: 1, },
y: 0, {
transition: { icon: Clock,
duration: 0.6, title: 'Limitation de conservation',
staggerChildren: 0.1, description: 'Vos données sont conservées uniquement le temps nécessaire.',
}, },
}, {
}; icon: Shield,
title: 'Intégrité et confidentialité',
const itemVariants = { description: 'Vos données sont protégées contre tout accès non autorisé.',
hidden: { opacity: 0, y: 20 }, },
visible: { {
opacity: 1, icon: FileText,
y: 0, title: 'Transparence',
transition: { duration: 0.5 }, description: 'Nous vous informons clairement sur l\'utilisation de vos données.',
}, },
}; ];
return ( const measures = [
<div className="min-h-screen bg-white"> {
<LandingHeader /> category: 'Mesures techniques',
items: [
{/* Hero Section */} 'Chiffrement des données au repos et en transit',
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> 'Authentification multi-facteurs',
<div className="absolute inset-0 opacity-10"> 'Journalisation des accès aux données',
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> 'Sauvegardes chiffrées régulières',
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> 'Pseudonymisation des données sensibles',
</div> ],
},
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8"> {
<motion.div category: 'Mesures organisationnelles',
initial={{ opacity: 0, y: 30 }} items: [
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} 'Délégué à la Protection des Données (DPO) désigné',
transition={{ duration: 0.8 }} 'Formation régulière des employés',
className="text-center" 'Politiques de sécurité documentées',
> 'Processus de gestion des incidents',
<motion.div 'Audits de conformité réguliers',
initial={{ scale: 0.8, opacity: 0 }} ],
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}} },
transition={{ duration: 0.6, delay: 0.2 }} ];
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
> const containerVariants = {
<Globe className="w-5 h-5 text-brand-turquoise" /> hidden: { opacity: 0, y: 50 },
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> visible: {
</motion.div> opacity: 1,
y: 0,
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> transition: {
{t('title1')} duration: 0.6,
<br /> staggerChildren: 0.1,
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> },
{t('title2')} },
</span> };
</h1>
const itemVariants = {
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed"> hidden: { opacity: 0, y: 20 },
{t('intro')} visible: {
</p> opacity: 1,
y: 0,
<div className="flex items-center justify-center space-x-4"> transition: { duration: 0.5 },
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg"> },
<CheckCircle className="w-5 h-5 text-brand-green" /> };
<span className="text-white text-sm">{t('badges.compliant')}</span>
</div> return (
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg"> <div className="min-h-screen bg-white">
<UserCheck className="w-5 h-5 text-brand-green" /> <LandingHeader />
<span className="text-white text-sm">{t('badges.dpo')}</span>
</div> {/* Hero Section */}
</div> <section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
</motion.div> <div className="absolute inset-0 opacity-10">
</div> <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" />
{/* Wave */} </div>
<div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> <div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
<path <motion.div
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z" initial={{ opacity: 0, y: 30 }}
fill="white" animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
/> transition={{ duration: 0.8 }}
</svg> className="text-center"
</div> >
</section> <motion.div
initial={{ scale: 0.8, opacity: 0 }}
{/* Your Rights */} animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
<section ref={contentRef} className="py-20"> transition={{ duration: 0.6, delay: 0.2 }}
<div className="max-w-7xl mx-auto px-6 lg:px-8"> className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
<motion.div >
initial={{ opacity: 0, y: 30 }} <Globe className="w-5 h-5 text-brand-turquoise" />
animate={isContentInView ? { opacity: 1, y: 0 } : {}} <span className="text-white/90 text-sm font-medium">Conformité européenne</span>
transition={{ duration: 0.8 }} </motion.div>
className="text-center mb-16"
> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> Conformité
{t('rightsTitle')} <br />
</h2> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> RGPD
{t('rightsSubtitle')} </span>
</p> </h1>
</motion.div>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
<motion.div Xpeditis s'engage à respecter le Règlement Général sur la Protection des Données (RGPD)
variants={containerVariants} et à garantir vos droits en matière de protection des données personnelles.
initial="hidden" </p>
animate={isContentInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" <div className="flex items-center justify-center space-x-4">
> <div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
{RIGHTS.map((right) => { <CheckCircle className="w-5 h-5 text-brand-green" />
const IconComponent = right.icon; <span className="text-white text-sm">Conforme RGPD</span>
return ( </div>
<motion.div <div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
key={right.key} <UserCheck className="w-5 h-5 text-brand-green" />
variants={itemVariants} <span className="text-white text-sm">DPO désigné</span>
whileHover={{ y: -5 }} </div>
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center" </div>
> </motion.div>
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4"> </div>
<IconComponent className="w-8 h-8 text-brand-turquoise" />
</div> {/* Wave */}
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3> <div className="absolute bottom-0 left-0 right-0">
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p> <svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
</motion.div> <path
); d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
})} fill="white"
</motion.div> />
</svg>
{/* Exercise Rights CTA */} </div>
<motion.div </section>
initial={{ opacity: 0, y: 30 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}} {/* Your Rights */}
transition={{ duration: 0.8, delay: 0.4 }} <section ref={contentRef} className="py-20">
className="mt-12 text-center" <div className="max-w-7xl mx-auto px-6 lg:px-8">
> <motion.div
<p className="text-gray-600 mb-4"> initial={{ opacity: 0, y: 30 }}
{t('rightsCta.text')} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
</p> transition={{ duration: 0.8 }}
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> className="text-center mb-16"
<Link >
href="/login" <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium" Vos droits RGPD
> </h2>
{t('rightsCta.login')} <p className="text-xl text-gray-600 max-w-2xl mx-auto">
</Link> Le RGPD vous confère des droits renforcés sur vos données personnelles
<a </p>
href="mailto:dpo@xpeditis.com" </motion.div>
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
> <motion.div
{t('rightsCta.dpo')} variants={containerVariants}
</a> initial="hidden"
</div> animate={isContentInView ? 'visible' : 'hidden'}
</motion.div> className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
</div> >
</section> {rights.map((right, index) => {
const IconComponent = right.icon;
{/* Principles */} return (
<section className="py-20 bg-gray-50"> <motion.div
<div className="max-w-7xl mx-auto px-6 lg:px-8"> key={index}
<motion.div variants={itemVariants}
initial={{ opacity: 0, y: 30 }} whileHover={{ y: -5 }}
whileInView={{ opacity: 1, y: 0 }} className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
viewport={{ once: true }} >
transition={{ duration: 0.8 }} <div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
className="text-center mb-16" <IconComponent className="w-8 h-8 text-brand-turquoise" />
> </div>
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
{t('principlesTitle')} <p className="text-gray-600">{right.description}</p>
</h2> </motion.div>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> );
{t('principlesSubtitle')} })}
</p> </motion.div>
</motion.div>
{/* Exercise Rights CTA */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <motion.div
{PRINCIPLES.map((principle, index) => { initial={{ opacity: 0, y: 30 }}
const IconComponent = principle.icon; animate={isContentInView ? { opacity: 1, y: 0 } : {}}
return ( transition={{ duration: 0.8, delay: 0.4 }}
<motion.div className="mt-12 text-center"
key={principle.key} >
initial={{ opacity: 0, scale: 0.9 }} <p className="text-gray-600 mb-4">
whileInView={{ opacity: 1, scale: 1 }} Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
viewport={{ once: true }} </p>
transition={{ duration: 0.5, delay: index * 0.1 }} <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100" <Link
> href="/login"
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4"> className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
<IconComponent className="w-6 h-6 text-brand-green" /> >
</div> Accéder à mon compte
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3> </Link>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p> <a
</motion.div> href="mailto:dpo@xpeditis.com"
); className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
})} >
</div> Contacter le DPO
</div> </a>
</section> </div>
</motion.div>
{/* Technical & Organizational Measures */} </div>
<section className="py-20"> </section>
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div {/* Principles */}
initial={{ opacity: 0, y: 30 }} <section className="py-20 bg-gray-50">
whileInView={{ opacity: 1, y: 0 }} <div className="max-w-7xl mx-auto px-6 lg:px-8">
viewport={{ once: true }} <motion.div
transition={{ duration: 0.8 }} initial={{ opacity: 0, y: 30 }}
className="text-center mb-16" whileInView={{ opacity: 1, y: 0 }}
> viewport={{ once: true }}
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> transition={{ duration: 0.8 }}
{t('measuresTitle')} className="text-center mb-16"
</h2> >
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresSubtitle')} Nos principes de protection des données
</p> </h2>
</motion.div> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
Des principes fondamentaux qui guident notre traitement des données
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> </p>
{MEASURES.map((key, index) => ( </motion.div>
<motion.div
key={key} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }} {principles.map((principle, index) => {
whileInView={{ opacity: 1, x: 0 }} const IconComponent = principle.icon;
viewport={{ once: true }} return (
transition={{ duration: 0.6 }} <motion.div
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl" key={index}
> initial={{ opacity: 0, scale: 0.9 }}
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3> whileInView={{ opacity: 1, scale: 1 }}
<ul className="space-y-4"> viewport={{ once: true }}
{MEASURE_ITEMS.map((itemKey) => ( transition={{ duration: 0.5, delay: index * 0.1 }}
<li key={itemKey} className="flex items-center space-x-3 text-white/80"> className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" /> >
<span>{t(`measures.${key}.${itemKey}` as any)}</span> <div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
</li> <IconComponent className="w-6 h-6 text-brand-green" />
))} </div>
</ul> <h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
</motion.div> <p className="text-gray-600 text-sm">{principle.description}</p>
))} </motion.div>
</div> );
</div> })}
</section> </div>
</div>
{/* Data Processing Register */} </section>
<section className="py-20 bg-gray-50">
<div className="max-w-4xl mx-auto px-6 lg:px-8"> {/* Technical & Organizational Measures */}
<motion.div <section className="py-20">
initial={{ opacity: 0, y: 30 }} <div className="max-w-7xl mx-auto px-6 lg:px-8">
whileInView={{ opacity: 1, y: 0 }} <motion.div
viewport={{ once: true }} initial={{ opacity: 0, y: 30 }}
transition={{ duration: 0.8 }} whileInView={{ opacity: 1, y: 0 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100" viewport={{ once: true }}
> transition={{ duration: 0.8 }}
<div className="flex items-start space-x-4"> className="text-center mb-16"
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0"> >
<FileText className="w-6 h-6 text-brand-turquoise" /> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
</div> Mesures de protection
<div> </h2>
<h3 className="text-2xl font-bold text-brand-navy mb-4"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('register.title')} Des mesures techniques et organisationnelles pour assurer la sécurité de vos données
</h3> </p>
<p className="text-gray-600 mb-6"> </motion.div>
{t('register.body')}
</p> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<ul className="space-y-3 text-gray-600"> {measures.map((measure, index) => (
{REGISTER_ITEMS.map((itemKey) => ( <motion.div
<li key={itemKey} className="flex items-center space-x-3"> key={index}
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" /> initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
<span>{t(`register.${itemKey}` as any)}</span> whileInView={{ opacity: 1, x: 0 }}
</li> viewport={{ once: true }}
))} transition={{ duration: 0.6 }}
</ul> className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
</div> >
</div> <h3 className="text-xl font-bold text-white mb-6">{measure.category}</h3>
</motion.div> <ul className="space-y-4">
</div> {measure.items.map((item, i) => (
</section> <li key={i} className="flex items-center space-x-3 text-white/80">
<CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
{/* Contact DPO */} <span>{item}</span>
<section className="py-20"> </li>
<div className="max-w-4xl mx-auto px-6 lg:px-8"> ))}
<motion.div </ul>
initial={{ opacity: 0, y: 30 }} </motion.div>
whileInView={{ opacity: 1, y: 0 }} ))}
viewport={{ once: true }} </div>
transition={{ duration: 0.8 }} </div>
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center" </section>
>
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" /> {/* Data Processing Register */}
<h3 className="text-2xl font-bold text-white mb-4"> <section className="py-20 bg-gray-50">
{t('dpo.title')} <div className="max-w-4xl mx-auto px-6 lg:px-8">
</h3> <motion.div
<p className="text-white/80 mb-6 max-w-2xl mx-auto"> initial={{ opacity: 0, y: 30 }}
{t('dpo.body')} whileInView={{ opacity: 1, y: 0 }}
</p> viewport={{ once: true }}
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> transition={{ duration: 0.8 }}
<a className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
href="mailto:dpo@xpeditis.com" >
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-colors font-medium" <div className="flex items-start space-x-4">
> <div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
<Mail className="w-5 h-5" /> <FileText className="w-6 h-6 text-brand-turquoise" />
<span>dpo@xpeditis.com</span> </div>
</a> <div>
<Link <h3 className="text-2xl font-bold text-brand-navy mb-4">
href="/privacy" Registre des traitements
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium" </h3>
> <p className="text-gray-600 mb-6">
{t('dpo.privacyLink')} Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
</Link> des données personnelles. Ce registre documente :
</div> </p>
</motion.div> <ul className="space-y-3 text-gray-600">
</div> <li className="flex items-center space-x-3">
</section> <CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les finalités de chaque traitement</span>
<LandingFooter /> </li>
</div> <li className="flex items-center space-x-3">
); <CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
} <span>Les catégories de données traitées</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les destinataires des données</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les durées de conservation</span>
</li>
<li className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>Les mesures de sécurité appliquées</span>
</li>
</ul>
</div>
</div>
</motion.div>
</div>
</section>
{/* Contact DPO */}
<section className="py-20">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
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">
Contacter notre DPO
</h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
Notre Délégué à la Protection des Données est à votre disposition pour toute question
relative au traitement de vos données personnelles ou à l'exercice de vos droits.
</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"
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-colors font-medium"
>
<Mail className="w-5 h-5" />
<span>dpo@xpeditis.com</span>
</a>
<Link
href="/privacy"
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
>
Politique de confidentialité
</Link>
</div>
</motion.div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -1,299 +1,291 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useRef } from 'react';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { useTranslations } from 'next-intl'; import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail } from 'lucide-react';
import { Cookie, Settings, BarChart3, Target, Shield, ToggleLeft, Mail, type LucideIcon } from 'lucide-react'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { LandingHeader, LandingFooter } from '@/components/layout';
export default function CookiesPage() {
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional'; const heroRef = useRef(null);
const contentRef = useRef(null);
interface CookieRow {
name: string; const isHeroInView = useInView(heroRef, { once: true });
purposeKey: string; const isContentInView = useInView(contentRef, { once: true });
durationKey: string;
} const cookieTypes = [
{
interface CookieTypeConfig { icon: Shield,
key: CookieTypeKey; title: 'Cookies essentiels',
icon: LucideIcon; description: 'Nécessaires au fonctionnement du site',
required: boolean; required: true,
cookies: CookieRow[]; cookies: [
} { name: 'session_id', purpose: 'Maintien de votre session de connexion', duration: 'Session' },
{ name: 'csrf_token', purpose: 'Protection contre les attaques CSRF', duration: 'Session' },
const COOKIE_TYPES: CookieTypeConfig[] = [ { name: 'cookie_consent', purpose: 'Mémorisation de vos préférences cookies', duration: '1 an' },
{ ],
key: 'essential', },
icon: Shield, {
required: true, icon: BarChart3,
cookies: [ title: 'Cookies analytiques',
{ name: 'session_id', purposeKey: 'session_id', durationKey: 'session' }, description: 'Nous aident à améliorer notre plateforme',
{ name: 'csrf_token', purposeKey: 'csrf_token', durationKey: 'session' }, required: false,
{ name: 'cookie_consent', purposeKey: 'cookie_consent', durationKey: 'year1' }, cookies: [
], { name: '_ga', purpose: 'Google Analytics - Identification des visiteurs', duration: '2 ans' },
}, { name: '_gid', purpose: 'Google Analytics - Identification des sessions', duration: '24 heures' },
{ { name: '_gat', purpose: 'Google Analytics - Limitation du taux de requêtes', duration: '1 minute' },
key: 'analytics', ],
icon: BarChart3, },
required: false, {
cookies: [ icon: Target,
{ name: '_ga', purposeKey: '_ga', durationKey: 'years2' }, title: 'Cookies marketing',
{ name: '_gid', purposeKey: '_gid', durationKey: 'hours24' }, description: 'Permettent de personnaliser les publicités',
{ name: '_gat', purposeKey: '_gat', durationKey: 'minute1' }, required: false,
], cookies: [
}, { name: '_fbp', purpose: 'Facebook Pixel - Suivi des conversions', duration: '3 mois' },
{ { name: 'li_fat_id', purpose: 'LinkedIn Insight - Attribution marketing', duration: '30 jours' },
key: 'marketing', { name: 'hubspotutk', purpose: 'HubSpot - Identification des visiteurs', duration: '13 mois' },
icon: Target, ],
required: false, },
cookies: [ {
{ name: '_fbp', purposeKey: '_fbp', durationKey: 'months3' }, icon: Settings,
{ name: 'li_fat_id', purposeKey: 'li_fat_id', durationKey: 'days30' }, title: 'Cookies fonctionnels',
{ name: 'hubspotutk', purposeKey: 'hubspotutk', durationKey: 'months13' }, description: 'Améliorent votre expérience utilisateur',
], required: false,
}, cookies: [
{ { name: 'language', purpose: 'Mémorisation de votre langue préférée', duration: '1 an' },
key: 'functional', { name: 'theme', purpose: 'Mémorisation du thème (clair/sombre)', duration: '1 an' },
icon: Settings, { name: 'recent_searches', purpose: 'Historique de vos recherches récentes', duration: '30 jours' },
required: false, ],
cookies: [ },
{ name: 'language', purposeKey: 'language', durationKey: 'year1' }, ];
{ name: 'theme', purposeKey: 'theme', durationKey: 'year1' },
{ name: 'recent_searches', purposeKey: 'recent_searches', durationKey: 'days30' }, const containerVariants = {
], hidden: { opacity: 0, y: 50 },
}, visible: {
]; opacity: 1,
y: 0,
export default function CookiesPage() { transition: {
const t = useTranslations('marketing.cookies'); duration: 0.6,
const tCommon = useTranslations('marketing.common'); staggerChildren: 0.1,
const heroRef = useRef(null); },
const contentRef = useRef(null); },
};
const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true }); const itemVariants = {
hidden: { opacity: 0, y: 20 },
const containerVariants = { visible: {
hidden: { opacity: 0, y: 50 }, opacity: 1,
visible: { y: 0,
opacity: 1, transition: { duration: 0.5 },
y: 0, },
transition: { };
duration: 0.6,
staggerChildren: 0.1, return (
}, <div className="min-h-screen bg-white">
}, <LandingHeader />
};
{/* Hero Section */}
const itemVariants = { <section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden">
hidden: { opacity: 0, y: 20 }, <div className="absolute inset-0 opacity-10">
visible: { <div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" />
opacity: 1, <div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" />
y: 0, </div>
transition: { duration: 0.5 },
}, <div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8">
}; <motion.div
initial={{ opacity: 0, y: 30 }}
return ( animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
<div className="min-h-screen bg-white"> transition={{ duration: 0.8 }}
<LandingHeader /> className="text-center"
>
{/* Hero Section */} <motion.div
<section ref={heroRef} className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy to-brand-navy/95 overflow-hidden"> initial={{ scale: 0.8, opacity: 0 }}
<div className="absolute inset-0 opacity-10"> animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise rounded-full blur-3xl" /> transition={{ duration: 0.6, delay: 0.2 }}
<div className="absolute bottom-20 right-20 w-96 h-96 bg-brand-green rounded-full blur-3xl" /> className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20"
</div> >
<Cookie className="w-5 h-5 text-brand-turquoise" />
<div className="relative z-10 max-w-7xl mx-auto px-6 lg:px-8"> <span className="text-white/90 text-sm font-medium">Transparence</span>
<motion.div </motion.div>
initial={{ opacity: 0, y: 30 }}
animate={isHeroInView ? { opacity: 1, y: 0 } : {}} <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
transition={{ duration: 0.8 }} Politique de
className="text-center" <br />
> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
<motion.div Cookies
initial={{ scale: 0.8, opacity: 0 }} </span>
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}} </h1>
transition={{ duration: 0.6, delay: 0.2 }}
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-8 border border-white/20" <p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
> Découvrez comment nous utilisons les cookies pour améliorer votre expérience
<Cookie className="w-5 h-5 text-brand-turquoise" /> sur Xpeditis et comment vous pouvez gérer vos préférences.
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> </p>
</motion.div>
<p className="text-white/60 text-sm">
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> Dernière mise à jour : Janvier 2025
{t('title1')} </p>
<br /> </motion.div>
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> </div>
{t('title2')}
</span> {/* Wave */}
</h1> <div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed"> <path
{t('intro')} d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z"
</p> fill="white"
/>
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</p> </svg>
</motion.div> </div>
</div> </section>
<div className="absolute bottom-0 left-0 right-0"> {/* Introduction */}
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> <section className="py-16 bg-gray-50">
<path <div className="max-w-4xl mx-auto px-6 lg:px-8">
d="M0,30 C240,50 480,10 720,30 C960,50 1200,10 1440,30 L1440,60 L0,60 Z" <motion.div
fill="white" initial={{ opacity: 0, y: 30 }}
/> whileInView={{ opacity: 1, y: 0 }}
</svg> viewport={{ once: true }}
</div> transition={{ duration: 0.8 }}
</section> className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
>
{/* Introduction */} <h2 className="text-2xl font-bold text-brand-navy mb-4">Qu'est-ce qu'un cookie ?</h2>
<section className="py-16 bg-gray-50"> <p className="text-gray-600 leading-relaxed mb-4">
<div className="max-w-4xl mx-auto px-6 lg:px-8"> Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone)
<motion.div lorsque vous visitez un site web. Les cookies permettent au site de mémoriser vos actions et
initial={{ opacity: 0, y: 30 }} préférences sur une période donnée.
whileInView={{ opacity: 1, y: 0 }} </p>
viewport={{ once: true }} <p className="text-gray-600 leading-relaxed">
transition={{ duration: 0.8 }} Les cookies ne contiennent pas d'informations personnellement identifiables et ne peuvent pas
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100" accéder aux données stockées sur votre appareil.
> </p>
<h2 className="text-2xl font-bold text-brand-navy mb-4">{t('introBoxTitle')}</h2> </motion.div>
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p> </div>
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p> </section>
</motion.div>
</div> {/* Cookie Types Section */}
</section> <section ref={contentRef} className="py-20">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
{/* Cookie Types Section */} <motion.div
<section ref={contentRef} className="py-20"> initial={{ opacity: 0, y: 30 }}
<div className="max-w-4xl mx-auto px-6 lg:px-8"> animate={isContentInView ? { opacity: 1, y: 0 } : {}}
<motion.div transition={{ duration: 0.8 }}
initial={{ opacity: 0, y: 30 }} className="text-center mb-12"
animate={isContentInView ? { opacity: 1, y: 0 } : {}} >
transition={{ duration: 0.8 }} <h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
className="text-center mb-12" <p className="text-gray-600">
> Nous utilisons différents types de cookies sur notre plateforme
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2> </p>
<p className="text-gray-600">{t('typesSubtitle')}</p> </motion.div>
</motion.div>
<motion.div
<motion.div variants={containerVariants}
variants={containerVariants} initial="hidden"
initial="hidden" animate={isContentInView ? 'visible' : 'hidden'}
animate={isContentInView ? 'visible' : 'hidden'} className="space-y-8"
className="space-y-8" >
> {cookieTypes.map((type, index) => {
{COOKIE_TYPES.map((type) => { const IconComponent = type.icon;
const IconComponent = type.icon; return (
return ( <motion.div
<motion.div key={index}
key={type.key} variants={itemVariants}
variants={itemVariants} className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100" >
> <div className="flex items-start justify-between mb-6">
<div className="flex items-start justify-between mb-6"> <div className="flex items-center space-x-4">
<div className="flex items-center space-x-4"> <div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center"> <IconComponent className="w-6 h-6 text-brand-turquoise" />
<IconComponent className="w-6 h-6 text-brand-turquoise" /> </div>
</div> <div>
<div> <h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
<h3 className="text-xl font-bold text-brand-navy"> <p className="text-gray-500 text-sm">{type.description}</p>
{t(`types.${type.key}.title`)} </div>
</h3> </div>
<p className="text-gray-500 text-sm"> {type.required ? (
{t(`types.${type.key}.description`)} <span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
</p> Requis
</div> </span>
</div> ) : (
{type.required ? ( <div className="flex items-center space-x-2">
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full"> <ToggleLeft className="w-8 h-8 text-gray-400" />
{t('required')} <span className="text-sm text-gray-500">Optionnel</span>
</span> </div>
) : ( )}
<div className="flex items-center space-x-2"> </div>
<ToggleLeft className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-500">{t('optional')}</span> <div className="overflow-x-auto">
</div> <table className="w-full text-sm">
)} <thead>
</div> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-brand-navy">Nom</th>
<div className="overflow-x-auto"> <th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
<table className="w-full text-sm"> <th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
<thead> </tr>
<tr className="border-b border-gray-200"> </thead>
<th className="text-left py-3 px-4 font-semibold text-brand-navy"> <tbody>
{t('tableHeaders.name')} {type.cookies.map((cookie, i) => (
</th> <tr key={i} className="border-b border-gray-100 last:border-0">
<th className="text-left py-3 px-4 font-semibold text-brand-navy"> <td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
{t('tableHeaders.purpose')} <td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
</th> <td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
<th className="text-left py-3 px-4 font-semibold text-brand-navy"> </tr>
{t('tableHeaders.duration')} ))}
</th> </tbody>
</tr> </table>
</thead> </div>
<tbody> </motion.div>
{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> </motion.div>
<td className="py-3 px-4 text-gray-600">
{t(`purposes.${cookie.purposeKey}` as any)} {/* How to manage cookies */}
</td> <motion.div
<td className="py-3 px-4 text-gray-500"> initial={{ opacity: 0, y: 30 }}
{t(`durations.${cookie.durationKey}` as any)} animate={isContentInView ? { opacity: 1, y: 0 } : {}}
</td> transition={{ duration: 0.8, delay: 0.4 }}
</tr> className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200"
))} >
</tbody> <h3 className="text-2xl font-bold text-brand-navy mb-4">Comment gérer vos cookies ?</h3>
</table> <div className="space-y-4 text-gray-600">
</div> <p>
</motion.div> Vous pouvez à tout moment modifier vos préférences en matière de cookies :
); </p>
})} <ul className="list-disc pl-6 space-y-2">
</motion.div> <li>Via notre bandeau de consentement accessible en bas de chaque page</li>
<li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
{/* How to manage cookies */} <li>En utilisant des outils tiers de gestion des cookies</li>
<motion.div </ul>
initial={{ opacity: 0, y: 30 }} <p className="text-sm text-gray-500 mt-4">
animate={isContentInView ? { opacity: 1, y: 0 } : {}} Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.
transition={{ duration: 0.8, delay: 0.4 }} </p>
className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200" </div>
> </motion.div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('manageTitle')}</h3>
<div className="space-y-4 text-gray-600"> {/* Contact Section */}
<p>{t('manageIntro')}</p> <motion.div
<ul className="list-disc pl-6 space-y-2"> initial={{ opacity: 0, y: 30 }}
<li>{t('manageBullet1')}</li> animate={isContentInView ? { opacity: 1, y: 0 } : {}}
<li>{t('manageBullet2')}</li> transition={{ duration: 0.8, delay: 0.6 }}
<li>{t('manageBullet3')}</li> className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center"
</ul> >
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p> <Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
</div> <h3 className="text-2xl font-bold text-white mb-4">Des questions sur les cookies ?</h3>
</motion.div> <p className="text-white/80 mb-6">
Notre équipe est disponible pour répondre à toutes vos questions
{/* Contact Section */} concernant l'utilisation des cookies sur notre plateforme.
<motion.div </p>
initial={{ opacity: 0, y: 30 }} <a
animate={isContentInView ? { opacity: 1, y: 0 } : {}} href="mailto:privacy@xpeditis.com"
transition={{ duration: 0.8, delay: 0.6 }} 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-colors font-medium"
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center" >
> <Mail className="w-5 h-5" />
<Mail className="w-12 h-12 text-brand-turquoise mx-auto mb-4" /> <span>privacy@xpeditis.com</span>
<h3 className="text-2xl font-bold text-white mb-4">{t('contact.title')}</h3> </a>
<p className="text-white/80 mb-6">{t('contact.body')}</p> </motion.div>
<a </div>
href="mailto:privacy@xpeditis.com" </section>
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-colors font-medium"
> <LandingFooter />
<Mail className="w-5 h-5" /> </div>
<span>privacy@xpeditis.com</span> );
</a> }
</motion.div>
</div>
</section>
<LandingFooter />
</div>
);
}

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin'; import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
interface Booking { interface Booking {
@ -27,10 +26,6 @@ interface Booking {
} }
export default function AdminBookingsPage() { export default function AdminBookingsPage() {
const t = useTranslations('dashboard.admin.bookings');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [bookings, setBookings] = useState<Booking[]>([]); const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -48,26 +43,26 @@ export default function AdminBookingsPage() {
}, []); }, []);
const handleDeleteBooking = async (bookingId: string) => { const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm(t('confirmDelete'))) return; if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
setDeletingId(bookingId); setDeletingId(bookingId);
try { try {
await deleteAdminBooking(bookingId); await deleteAdminBooking(bookingId);
setBookings(prev => prev.filter(b => b.id !== bookingId)); setBookings(prev => prev.filter(b => b.id !== bookingId));
} catch (err: any) { } catch (err: any) {
setError(err.message || t('deleteError')); setError(err.message || 'Erreur lors de la suppression');
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
}; };
const handleValidateTransfer = async (bookingId: string) => { const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm(t('confirmValidate'))) return; if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
setValidatingId(bookingId); setValidatingId(bookingId);
try { try {
await validateBankTransfer(bookingId); await validateBankTransfer(bookingId);
await fetchBookings(); await fetchBookings();
} catch (err: any) { } catch (err: any) {
setError(err.message || t('validateError')); setError(err.message || 'Erreur lors de la validation du virement');
} finally { } finally {
setValidatingId(null); setValidatingId(null);
} }
@ -80,7 +75,7 @@ export default function AdminBookingsPage() {
setBookings(response.bookings || []); setBookings(response.bookings || []);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('loadError')); setError(err.message || 'Impossible de charger les réservations');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -99,12 +94,15 @@ export default function AdminBookingsPage() {
}; };
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const key = status.toUpperCase(); const labels: Record<string, string> = {
const allowed = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED']; PENDING_PAYMENT: 'Paiement en attente',
if (allowed.includes(key)) { PENDING_BANK_TRANSFER: 'Virement à valider',
return t(`status.${key}` as any); PENDING: 'En attente transporteur',
} ACCEPTED: 'Accepté',
return status; REJECTED: 'Rejeté',
CANCELLED: 'Annulé',
};
return labels[status.toUpperCase()] || status;
}; };
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`; const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
@ -132,7 +130,7 @@ export default function AdminBookingsPage() {
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p> <p className="mt-4 text-gray-600">Chargement des réservations...</p>
</div> </div>
</div> </div>
); );
@ -142,38 +140,38 @@ export default function AdminBookingsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1> <h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
{t('subtitle')} Toutes les réservations de la plateforme
</p> </p>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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.total')}</div> <div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div> <div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
</div> </div>
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4"> <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">Virements à valider</div>
<div className="text-2xl font-bold text-amber-700 mt-1"> <div className="text-2xl font-bold text-amber-700 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length} {bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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">En attente transporteur</div>
<div className="text-2xl font-bold text-yellow-600 mt-1"> <div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length} {bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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.accepted')}</div> <div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
<div className="text-2xl font-bold text-green-600 mt-1"> <div className="text-2xl font-bold text-green-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length} {bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div> </div>
</div> </div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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.rejected')}</div> <div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
<div className="text-2xl font-bold text-red-600 mt-1"> <div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length} {bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div> </div>
@ -184,29 +182,29 @@ export default function AdminBookingsPage() {
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"> <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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <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">Recherche</label>
<input <input
type="text" type="text"
placeholder={t('search.placeholder')} placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
className="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 text-sm" className="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 text-sm"
/> />
</div> </div>
<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">Statut</label>
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value)} onChange={e => setFilterStatus(e.target.value)}
className="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 text-sm" className="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 text-sm"
> >
<option value="all">{t('filter.all')}</option> <option value="all">Tous les statuts</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option> <option value="pending_bank_transfer">Virement à valider</option>
<option value="pending_payment">{t('status.PENDING_PAYMENT')}</option> <option value="pending_payment">Paiement en attente</option>
<option value="pending">{t('status.PENDING')}</option> <option value="pending">En attente transporteur</option>
<option value="accepted">{t('status.ACCEPTED')}</option> <option value="accepted">Accepté</option>
<option value="rejected">{t('status.REJECTED')}</option> <option value="rejected">Rejeté</option>
<option value="cancelled">{t('status.CANCELLED')}</option> <option value="cancelled">Annulé</option>
</select> </select>
</div> </div>
</div> </div>
@ -226,25 +224,25 @@ export default function AdminBookingsPage() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.bookingNumber')} N° Booking
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')} Route
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.cargo')} Cargo
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.carrier')} Transporteur
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')} Statut
</th> </th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.date')} Date
</th> </th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')} Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@ -252,7 +250,7 @@ export default function AdminBookingsPage() {
{filteredBookings.length === 0 ? ( {filteredBookings.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500"> <td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
{t('table.empty')} Aucune réservation trouvée
</td> </td>
</tr> </tr>
) : ( ) : (
@ -278,11 +276,11 @@ export default function AdminBookingsPage() {
<div className="text-sm text-gray-900"> <div className="text-sm text-gray-900">
{booking.containerType} {booking.containerType}
{booking.palletCount != null && ( {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} pal.</span>
)} )}
</div> </div>
<div className="text-xs text-gray-500 space-x-2"> <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()} kg</span>}
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>} {booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
</div> </div>
</td> </td>
@ -301,7 +299,7 @@ export default function AdminBookingsPage() {
{/* Date */} {/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500"> <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('fr-FR')}
</td> </td>
{/* Actions */} {/* Actions */}
@ -359,7 +357,7 @@ export default function AdminBookingsPage() {
<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="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" /> <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> </svg>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span> <span className="text-sm font-medium text-gray-700">Voir les détails</span>
</button> </button>
{(() => { {(() => {
const booking = bookings.find(b => b.id === openMenuId); const booking = bookings.find(b => b.id === openMenuId);
@ -377,7 +375,7 @@ export default function AdminBookingsPage() {
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
<span className="text-sm font-medium text-green-700">{t('menu.validateTransfer')}</span> <span className="text-sm font-medium text-green-700">Valider virement</span>
</button> </button>
) : null; ) : null;
})()} })()}
@ -394,7 +392,7 @@ export default function AdminBookingsPage() {
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span> <span className="text-sm font-medium text-red-600">Supprimer</span>
</button> </button>
</div> </div>
</div> </div>
@ -406,7 +404,7 @@ export default function AdminBookingsPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full"> <div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2> <h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
<button <button
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }} onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
@ -420,13 +418,13 @@ export default function AdminBookingsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <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">N° Booking</label>
<div className="mt-1 text-lg font-semibold text-gray-900"> <div className="mt-1 text-lg font-semibold text-gray-900">
{selectedBooking.bookingNumber || getShortId(selectedBooking)} {selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div> </div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.status')}</label> <label className="block text-sm font-medium text-gray-500">Statut</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}> <span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{getStatusLabel(selectedBooking.status)} {getStatusLabel(selectedBooking.status)}
</span> </span>
@ -434,45 +432,45 @@ export default function AdminBookingsPage() {
</div> </div>
<div className="border-t pt-4"> <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">Route</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.origin')}</label> <label className="block text-sm font-medium text-gray-500">Origine</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || t('modal.none')}</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.destination')}</label> <label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || t('modal.none')}</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <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">Cargo & Transporteur</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.carrier')}</label> <label className="block text-sm font-medium text-gray-500">Transporteur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || t('modal.none')}</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.containerType')}</label> <label className="block text-sm font-medium text-gray-500">Type conteneur</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
</div> </div>
{selectedBooking.palletCount != null && ( {selectedBooking.palletCount != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.pallets')}</label> <label className="block text-sm font-medium text-gray-500">Palettes</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
</div> </div>
)} )}
{selectedBooking.weightKG != null && ( {selectedBooking.weightKG != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.weight')}</label> <label className="block text-sm font-medium text-gray-500">Poids</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString(dateLocale)} kg</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
</div> </div>
)} )}
{selectedBooking.volumeCBM != null && ( {selectedBooking.volumeCBM != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">{t('modal.volume')}</label> <label className="block text-sm font-medium text-gray-500">Volume</label>
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div> <div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
</div> </div>
)} )}
@ -481,18 +479,18 @@ export default function AdminBookingsPage() {
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && ( {(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
<div className="border-t pt-4"> <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">Prix</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && ( {selectedBooking.priceEUR != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">EUR</label> <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()} </div>
</div> </div>
)} )}
{selectedBooking.priceUSD != null && ( {selectedBooking.priceUSD != null && (
<div> <div>
<label className="block text-sm font-medium text-gray-500">USD</label> <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()} $</div>
</div> </div>
)} )}
</div> </div>
@ -500,18 +498,18 @@ export default function AdminBookingsPage() {
)} )}
<div className="border-t pt-4"> <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">Dates</h3>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<label className="block text-gray-500">{t('modal.createdAt')}</label> <label className="block text-gray-500">Créée le</label>
<div className="mt-1 text-gray-900"> <div className="mt-1 text-gray-900">
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString(dateLocale)} {new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
</div> </div>
</div> </div>
{selectedBooking.updatedAt && ( {selectedBooking.updatedAt && (
<div> <div>
<label className="block text-gray-500">{t('modal.updatedAt')}</label> <label className="block text-gray-500">Mise à jour</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('fr-FR')}</div>
</div> </div>
)} )}
</div> </div>
@ -528,7 +526,7 @@ export default function AdminBookingsPage() {
disabled={validatingId === selectedBooking.id} 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" 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 ? 'Validation...' : '✓ Valider le virement'}
</button> </button>
</div> </div>
)} )}
@ -539,7 +537,7 @@ export default function AdminBookingsPage() {
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" className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
> >
{t('modal.close')} Fermer
</button> </button>
</div> </div>
</div> </div>

View File

@ -10,7 +10,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -28,10 +27,6 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
export default function AdminCsvRatesPage() { export default function AdminCsvRatesPage() {
const t = useTranslations('dashboard.admin.csvRates');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [files, setFiles] = useState<CsvFileInfo[]>([]); const [files, setFiles] = useState<CsvFileInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -44,7 +39,7 @@ export default function AdminCsvRatesPage() {
const data = await listCsvFiles(); const data = await listCsvFiles();
setFiles(data.files || []); setFiles(data.files || []);
} catch (err: any) { } catch (err: any) {
setError(err?.message || t('loadError')); setError(err?.message || 'Erreur lors du chargement des fichiers');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -55,16 +50,16 @@ export default function AdminCsvRatesPage() {
}, []); }, []);
const handleDelete = async (filename: string) => { const handleDelete = async (filename: string) => {
if (!confirm(t('confirmDelete', { filename }))) { if (!confirm(`Êtes-vous sûr de vouloir supprimer le fichier ${filename} ?`)) {
return; return;
} }
try { try {
await deleteCsvFile(filename); await deleteCsvFile(filename);
alert(t('deleteSuccess', { filename })); alert(`Fichier supprimé: ${filename}`);
fetchFiles(); // Refresh list fetchFiles(); // Refresh list
} catch (err: any) { } catch (err: any) {
alert(t('deleteError', { message: err?.message || t('deleteFailedFallback') })); alert(`Erreur: ${err?.message || 'Impossible de supprimer le fichier'}`);
} }
}; };
@ -72,12 +67,12 @@ export default function AdminCsvRatesPage() {
<div className="container mx-auto py-8 space-y-6"> <div className="container mx-auto py-8 space-y-6">
{/* Page Header */} {/* Page Header */}
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">{t('title')}</h1> <h1 className="text-3xl font-bold tracking-tight">Gestion des tarifs CSV</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
{t('subtitle')} Interface d'administration pour gérer les fichiers CSV de tarifs maritimes
</p> </p>
<Badge variant="destructive" className="mt-2"> <Badge variant="destructive" className="mt-2">
{t('adminBadge')} ADMIN SEULEMENT
</Badge> </Badge>
</div> </div>
@ -89,9 +84,9 @@ export default function AdminCsvRatesPage() {
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>{t('cardTitle')}</CardTitle> <CardTitle>Configurations CSV actives</CardTitle>
<CardDescription> <CardDescription>
{t('cardDescription')} Liste de toutes les compagnies avec fichiers CSV configurés
</CardDescription> </CardDescription>
</div> </div>
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}> <Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
@ -116,19 +111,19 @@ export default function AdminCsvRatesPage() {
</div> </div>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
{t('empty')} Aucun fichier trouvé. Uploadez un fichier CSV pour commencer.
</div> </div>
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('table.filename')}</TableHead> <TableHead>Fichier</TableHead>
<TableHead>{t('table.size')}</TableHead> <TableHead>Taille</TableHead>
<TableHead>{t('table.rows')}</TableHead> <TableHead>Lignes</TableHead>
<TableHead>{t('table.uploadedAt')}</TableHead> <TableHead>Date d'upload</TableHead>
<TableHead>{t('table.email')}</TableHead> <TableHead>Email</TableHead>
<TableHead>{t('table.actions')}</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -140,14 +135,14 @@ export default function AdminCsvRatesPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{file.rowCount ? ( {file.rowCount ? (
<span className="font-semibold">{t('table.rowCount', { count: file.rowCount })}</span> <span className="font-semibold">{file.rowCount} lignes</span>
) : ( ) : (
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{new Date(file.uploadedAt).toLocaleDateString(dateLocale)} {new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -176,20 +171,23 @@ export default function AdminCsvRatesPage() {
{/* Info Card */} {/* Info Card */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('infoTitle')}</CardTitle> <CardTitle>Informations</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
<p> <p>
<strong>{t('info.formatLabel')}</strong> {t('info.formatBody')} <strong>Format CSV requis :</strong> Consultez la documentation pour la liste complète
des colonnes obligatoires.
</p> </p>
<p> <p>
<strong>{t('info.sizeLabel')}</strong> {t('info.sizeBody')} <strong>Taille maximale :</strong> 10 MB par fichier
</p> </p>
<p> <p>
<strong>{t('info.updateLabel')}</strong> {t('info.updateBody')} <strong>Mise à jour :</strong> Uploader un nouveau fichier pour une compagnie existante
écrasera l'ancien fichier.
</p> </p>
<p> <p>
<strong>{t('info.validationLabel')}</strong> {t('info.validationBody')} <strong>Validation :</strong> Le système valide automatiquement la structure du CSV lors
de l'upload.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { import {
Download, Download,
RefreshCw, RefreshCw,
@ -13,7 +12,6 @@ import {
Server, Server,
} from 'lucide-react'; } from 'lucide-react';
import { get, download } from '@/lib/api/client'; import { get, download } from '@/lib/api/client';
import { PageHeader } from '@/components/ui/PageHeader';
const LOGS_PREFIX = '/api/v1/logs'; const LOGS_PREFIX = '/api/v1/logs';
@ -105,10 +103,6 @@ function StatCard({
// ─── Page ───────────────────────────────────────────────────────────────────── // ─── Page ─────────────────────────────────────────────────────────────────────
export default function AdminLogsPage() { export default function AdminLogsPage() {
const t = useTranslations('dashboard.admin.logs');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const [services, setServices] = useState<string[]>([]); const [services, setServices] = useState<string[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -195,68 +189,71 @@ export default function AdminLogsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader {/* Header */}
title={t('title')} <div className="flex items-center justify-between">
description={t('subtitle')} <div>
actions={ <h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
<div className="flex items-center gap-2"> <p className="mt-1 text-sm text-gray-500">
Visualisation et export des logs applicatifs en temps réel
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchLogs}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</button>
<div className="relative group">
<button <button
onClick={fetchLogs} disabled={exportLoading || loading}
disabled={loading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
> >
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> <Download className="h-4 w-4" />
<span className="hidden sm:inline">{t('refresh')}</span> {exportLoading ? 'Export...' : 'Exporter'}
</button> </button>
<div className="relative group"> <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 <button
disabled={exportLoading || loading} onClick={() => handleExport('csv')}
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" className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
> >
<Download className="h-4 w-4" /> Télécharger CSV
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span> </button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger JSON
</button> </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
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t('downloadCsv')}
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
{t('downloadJson')}
</button>
</div>
</div> </div>
</div> </div>
} </div>
/> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label={t('stats.total')} label="Total logs"
value={total} value={total}
icon={Activity} icon={Activity}
color="bg-blue-100 text-blue-600" color="bg-blue-100 text-blue-600"
/> />
<StatCard <StatCard
label={t('stats.errors')} label="Erreurs"
value={countByLevel('error') + countByLevel('fatal')} value={countByLevel('error') + countByLevel('fatal')}
icon={AlertTriangle} icon={AlertTriangle}
color="bg-red-100 text-red-600" color="bg-red-100 text-red-600"
/> />
<StatCard <StatCard
label={t('stats.warnings')} label="Warnings"
value={countByLevel('warn')} value={countByLevel('warn')}
icon={AlertTriangle} icon={AlertTriangle}
color="bg-yellow-100 text-yellow-600" color="bg-yellow-100 text-yellow-600"
/> />
<StatCard <StatCard
label={t('stats.info')} label="Info"
value={countByLevel('info')} value={countByLevel('info')}
icon={Info} icon={Info}
color="bg-green-100 text-green-600" color="bg-green-100 text-green-600"
@ -267,18 +264,18 @@ export default function AdminLogsPage() {
<div className="bg-white rounded-lg border p-4"> <div className="bg-white rounded-lg border p-4">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-gray-500" /> <Filter className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700">{t('filters.title')}</h2> <h2 className="text-sm font-semibold text-gray-700">Filtres</h2>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */} {/* Service */}
<div> <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">Service</label>
<select <select
value={filters.service} value={filters.service}
onChange={e => setFilter('service', e.target.value)} onChange={e => setFilter('service', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
> >
<option value="all">{t('filters.all')}</option> <option value="all">Tous</option>
{services.map(s => ( {services.map(s => (
<option key={s} value={s}> <option key={s} value={s}>
{s} {s}
@ -289,13 +286,13 @@ export default function AdminLogsPage() {
{/* Level */} {/* Level */}
<div> <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">Niveau</label>
<select <select
value={filters.level} value={filters.level}
onChange={e => setFilter('level', e.target.value)} onChange={e => setFilter('level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
> >
<option value="all">{t('filters.all')}</option> <option value="all">Tous</option>
<option value="error">Error</option> <option value="error">Error</option>
<option value="fatal">Fatal</option> <option value="fatal">Fatal</option>
<option value="warn">Warn</option> <option value="warn">Warn</option>
@ -306,10 +303,10 @@ export default function AdminLogsPage() {
{/* Search */} {/* Search */}
<div> <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">Recherche</label>
<input <input
type="text" type="text"
placeholder={t('filters.searchPlaceholder')} placeholder="Texte libre..."
value={filters.search} value={filters.search}
onChange={e => setFilter('search', e.target.value)} onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()} onKeyDown={e => e.key === 'Enter' && fetchLogs()}
@ -319,7 +316,7 @@ export default function AdminLogsPage() {
{/* Start */} {/* Start */}
<div> <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">Début</label>
<input <input
type="datetime-local" type="datetime-local"
value={filters.startDate} value={filters.startDate}
@ -330,7 +327,7 @@ export default function AdminLogsPage() {
{/* End */} {/* End */}
<div> <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">Fin</label>
<input <input
type="datetime-local" type="datetime-local"
value={filters.endDate} value={filters.endDate}
@ -341,7 +338,7 @@ export default function AdminLogsPage() {
{/* Limit + Apply */} {/* Limit + Apply */}
<div className="flex flex-col justify-end gap-2"> <div className="flex flex-col justify-end gap-2">
<label className="block text-xs font-medium text-gray-500">{t('filters.limit')}</label> <label className="block text-xs font-medium text-gray-500">Limite</label>
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
value={filters.limit} value={filters.limit}
@ -358,7 +355,7 @@ export default function AdminLogsPage() {
disabled={loading} disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap" className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
> >
{t('filters.apply')} Filtrer
</button> </button>
</div> </div>
</div> </div>
@ -370,10 +367,10 @@ export default function AdminLogsPage() {
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2"> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" /> <AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span className="text-sm"> <span className="text-sm">
{t('errorBanner')} <strong>{error}</strong> Impossible de contacter le log-exporter : <strong>{error}</strong>
<br /> <br />
<span className="text-xs text-red-500"> <span className="text-xs text-red-500">
{t('errorHint')} Vérifiez que le backend et le log-exporter sont démarrés.
</span> </span>
</span> </span>
</div> </div>
@ -385,12 +382,12 @@ export default function AdminLogsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Server className="h-4 w-4 text-gray-500" /> <Server className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{loading ? t('loading') : t('entries', { count: total })} {loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
</span> </span>
</div> </div>
{!loading && logs.length > 0 && ( {!loading && logs.length > 0 && (
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{t('clickHint')} Cliquer sur une ligne pour les détails
</span> </span>
)} )}
</div> </div>
@ -402,7 +399,7 @@ export default function AdminLogsPage() {
) : logs.length === 0 && !error ? ( ) : logs.length === 0 && !error ? (
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2"> <div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
<Bug className="h-8 w-8" /> <Bug className="h-8 w-8" />
<p className="text-sm">{t('empty')}</p> <p className="text-sm">Aucun log trouvé pour ces filtres</p>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -410,22 +407,22 @@ export default function AdminLogsPage() {
<thead className="bg-gray-50 border-b"> <thead className="bg-gray-50 border-b">
<tr> <tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
{t('table.timestamp')} Timestamp
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
{t('table.service')} Service
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
{t('table.level')} Niveau
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
{t('table.context')} Contexte
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
{t('table.message')} Message
</th> </th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap"> <th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
{t('table.req')} Req / Status
</th> </th>
</tr> </tr>
</thead> </thead>
@ -438,7 +435,7 @@ export default function AdminLogsPage() {
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`} className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
> >
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap"> <td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString(dateLocale, { {new Date(log.timestamp).toLocaleString('fr-FR', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
hour: '2-digit', hour: '2-digit',
@ -495,25 +492,25 @@ export default function AdminLogsPage() {
<td colSpan={6} className="px-4 py-3"> <td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.timestamp')}</span> <span className="font-semibold text-gray-600">Timestamp</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p> <p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div> </div>
{log.reqId && ( {log.reqId && (
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.requestId')}</span> <span className="font-semibold text-gray-600">Request ID</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p> <p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
</div> </div>
)} )}
{log.response_time_ms && ( {log.response_time_ms && (
<div> <div>
<span className="font-semibold text-gray-600">{t('detail.duration')}</span> <span className="font-semibold text-gray-600">Durée</span>
<p className="font-mono text-gray-800 mt-0.5"> <p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms {log.response_time_ms} ms
</p> </p>
</div> </div>
)} )}
<div className="col-span-2 md:col-span-4"> <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">Message complet</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"> <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 {log.error
? `[ERROR] ${log.error}\n\n${log.message}` ? `[ERROR] ${log.error}\n\n${log.message}`

View File

@ -1,470 +1,463 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin'; import { createUser } from '@/lib/api/users';
import { createUser } from '@/lib/api/users'; import { getAllOrganizations } from '@/lib/api/admin';
import { getAllOrganizations } from '@/lib/api/admin'; import type { UserRole } from '@/types/api';
import type { UserRole } from '@/types/api';
import { PageHeader } from '@/components/ui/PageHeader'; interface User {
id: string;
interface User { email: string;
id: string; firstName: string;
email: string; lastName: string;
firstName: string; role: UserRole;
lastName: string; organizationId: string;
role: UserRole; organizationName?: string;
organizationId: string; isActive: boolean;
organizationName?: string; createdAt: string;
isActive: boolean; }
createdAt: string;
} interface Organization {
id: string;
interface Organization { name: string;
id: string; }
name: string;
} export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]);
export default function AdminUsersPage() { const [organizations, setOrganizations] = useState<Organization[]>([]);
const t = useTranslations('dashboard.admin.users'); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>([]); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [showCreateModal, setShowCreateModal] = useState(false);
const [loading, setLoading] = useState(true); const [showEditModal, setShowEditModal] = useState(false);
const [error, setError] = useState<string | null>(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false); // Form state
const [showEditModal, setShowEditModal] = useState(false); const [formData, setFormData] = useState<{
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); email: string;
firstName: string;
// Form state lastName: string;
const [formData, setFormData] = useState<{ role: UserRole;
email: string; organizationId: string;
firstName: string; password: string;
lastName: string; }>({
role: UserRole; email: '',
organizationId: string; firstName: '',
password: string; lastName: '',
}>({ role: 'USER',
email: '', organizationId: '',
firstName: '', password: '',
lastName: '', });
role: 'USER',
organizationId: '', // Fetch users and organizations
password: '', useEffect(() => {
}); fetchData();
}, []);
useEffect(() => {
fetchData(); const fetchData = async () => {
}, []); try {
setLoading(true);
const fetchData = async () => { const [usersResponse, orgsResponse] = await Promise.all([
try { getAllUsers(),
setLoading(true); getAllOrganizations(),
const [usersResponse, orgsResponse] = await Promise.all([ ]);
getAllUsers(),
getAllOrganizations(), setUsers(usersResponse.users || []);
]); setOrganizations(orgsResponse.organizations || []);
setError(null);
setUsers(usersResponse.users || []); } catch (err: any) {
setOrganizations(orgsResponse.organizations || []); setError(err.message || 'Failed to load data');
setError(null); } finally {
} catch (err: any) { setLoading(false);
setError(err.message || t('loadError')); }
} finally { };
setLoading(false);
} const handleCreate = async (e: React.FormEvent) => {
}; e.preventDefault();
try {
const handleCreate = async (e: React.FormEvent) => { await createUser(formData);
e.preventDefault(); await fetchData();
try { setShowCreateModal(false);
await createUser(formData); resetForm();
await fetchData(); } catch (err: any) {
setShowCreateModal(false); alert(err.message || 'Failed to create user');
resetForm(); }
} catch (err: any) { };
alert(err.message || t('createError'));
} const handleUpdate = async (e: React.FormEvent) => {
}; e.preventDefault();
if (!selectedUser) return;
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault(); try {
if (!selectedUser) return; await updateAdminUser(selectedUser.id, {
firstName: formData.firstName,
try { lastName: formData.lastName,
await updateAdminUser(selectedUser.id, { role: formData.role,
firstName: formData.firstName, isActive: selectedUser.isActive,
lastName: formData.lastName, });
role: formData.role, await fetchData();
isActive: selectedUser.isActive, setShowEditModal(false);
}); setSelectedUser(null);
await fetchData(); resetForm();
setShowEditModal(false); } catch (err: any) {
setSelectedUser(null); alert(err.message || 'Failed to update user');
resetForm(); }
} catch (err: any) { };
alert(err.message || t('updateError'));
} const handleDelete = async () => {
}; if (!selectedUser) return;
const handleDelete = async () => { try {
if (!selectedUser) return; await deleteAdminUser(selectedUser.id);
await fetchData();
try { setShowDeleteConfirm(false);
await deleteAdminUser(selectedUser.id); setSelectedUser(null);
await fetchData(); } catch (err: any) {
setShowDeleteConfirm(false); alert(err.message || 'Failed to delete user');
setSelectedUser(null); }
} catch (err: any) { };
alert(err.message || t('deleteError'));
} const resetForm = () => {
}; setFormData({
email: '',
const resetForm = () => { firstName: '',
setFormData({ lastName: '',
email: '', role: 'USER',
firstName: '', organizationId: '',
lastName: '', password: '',
role: 'USER', });
organizationId: '', };
password: '',
}); const openEditModal = (user: User) => {
}; setSelectedUser(user);
setFormData({
const openEditModal = (user: User) => { email: user.email,
setSelectedUser(user); firstName: user.firstName,
setFormData({ lastName: user.lastName,
email: user.email, role: user.role,
firstName: user.firstName, organizationId: user.organizationId,
lastName: user.lastName, password: '',
role: user.role, });
organizationId: user.organizationId, setShowEditModal(true);
password: '', };
});
setShowEditModal(true); const openDeleteConfirm = (user: User) => {
}; setSelectedUser(user);
setShowDeleteConfirm(true);
const openDeleteConfirm = (user: User) => { };
setSelectedUser(user);
setShowDeleteConfirm(true); if (loading) {
}; return (
<div className="flex items-center justify-center h-96">
const getRoleLabel = (role: string) => { <div className="text-center">
const allowed = ['USER', 'MANAGER', 'ADMIN', 'VIEWER']; <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
if (allowed.includes(role)) { <p className="mt-4 text-gray-600">Loading users...</p>
return t(`roles.${role}` as any); </div>
} </div>
return role; );
}; }
if (loading) { return (
return ( <div className="space-y-6">
<div className="flex items-center justify-center h-96"> {/* Header */}
<div className="text-center"> <div className="flex items-center justify-between">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div> <div>
<p className="mt-4 text-gray-600">{t('loading')}</p> <h1 className="text-2xl font-bold text-gray-900">User Management</h1>
</div> <p className="mt-1 text-sm text-gray-500">
</div> Manage all users in the system
); </p>
} </div>
<button
return ( onClick={() => setShowCreateModal(true)}
<div className="space-y-6"> className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
<PageHeader >
title={t('title')} + Create User
description={t('subtitle')} </button>
actions={ </div>
<button
onClick={() => setShowCreateModal(true)} {/* Error Message */}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" {error && (
> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{t('create')} {error}
</button> </div>
} )}
/>
{/* Users Table */}
{/* Error Message */} <div className="bg-white rounded-lg shadow overflow-hidden">
{error && ( <table className="min-w-full divide-y divide-gray-200">
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg"> <thead className="bg-gray-50">
{error} <tr>
</div> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
)} User
</th>
{/* Users Table */} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="bg-white rounded-lg shadow overflow-hidden"> Email
<table className="min-w-full divide-y divide-gray-200"> </th>
<thead className="bg-gray-50"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<tr> Role
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </th>
{t('table.user')} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> Organization
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </th>
{t('table.email')} <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> Status
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </th>
{t('table.role')} <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> Actions
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </th>
{t('table.organization')} </tr>
</th> </thead>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <tbody className="bg-white divide-y divide-gray-200">
{t('table.status')} {users.map(user => (
</th> <tr key={user.id} className="hover:bg-gray-50">
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <td className="px-6 py-4 whitespace-nowrap">
{t('table.actions')} <div className="text-sm font-medium text-gray-900">
</th> {user.firstName} {user.lastName}
</tr> </div>
</thead> </td>
<tbody className="bg-white divide-y divide-gray-200"> <td className="px-6 py-4 whitespace-nowrap">
{users.map(user => ( <div className="text-sm text-gray-500">{user.email}</div>
<tr key={user.id} className="hover:bg-gray-50"> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900"> <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
{user.firstName} {user.lastName} user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' :
</div> user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
</td> 'bg-gray-100 text-gray-800'
<td className="px-6 py-4 whitespace-nowrap"> }`}>
<div className="text-sm text-gray-500">{user.email}</div> {user.role}
</td> </span>
<td className="px-6 py-4 whitespace-nowrap"> </td>
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' : {user.organizationName || user.organizationId}
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' : </td>
'bg-gray-100 text-gray-800' <td className="px-6 py-4 whitespace-nowrap">
}`}> <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
{getRoleLabel(user.role)} user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
</span> }`}>
</td> {user.isActive ? 'Active' : 'Inactive'}
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> </span>
{user.organizationName || user.organizationId} </td>
</td> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<td className="px-6 py-4 whitespace-nowrap"> <button
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ onClick={() => openEditModal(user)}
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' className="text-blue-600 hover:text-blue-900"
}`}> >
{user.isActive ? t('active') : t('inactive')} Edit
</span> </button>
</td> <button
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2"> onClick={() => openDeleteConfirm(user)}
<button className="text-red-600 hover:text-red-900"
onClick={() => openEditModal(user)} >
className="text-blue-600 hover:text-blue-900" Delete
> </button>
{t('edit')} </td>
</button> </tr>
<button ))}
onClick={() => openDeleteConfirm(user)} </tbody>
className="text-red-600 hover:text-red-900" </table>
> </div>
{t('delete')}
</button> {/* Create Modal */}
</td> {showCreateModal && (
</tr> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
))} <div className="bg-white rounded-lg p-6 max-w-md w-full">
</tbody> <h2 className="text-xl font-bold mb-4">Create New User</h2>
</table> <form onSubmit={handleCreate} className="space-y-4">
</div> <div>
<label className="block text-sm font-medium text-gray-700">Email</label>
{/* Create Modal */} <input
{showCreateModal && ( type="email"
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> required
<div className="bg-white rounded-lg p-6 max-w-md w-full"> value={formData.email}
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2> onChange={e => setFormData({ ...formData, email: e.target.value })}
<form onSubmit={handleCreate} className="space-y-4"> 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> />
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label> </div>
<input <div>
type="email" <label className="block text-sm font-medium text-gray-700">First Name</label>
required <input
value={formData.email} type="text"
onChange={e => setFormData({ ...formData, email: e.target.value })} required
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" value={formData.firstName}
/> onChange={e => setFormData({ ...formData, firstName: e.target.value })}
</div> 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> />
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> </div>
<input <div>
type="text" <label className="block text-sm font-medium text-gray-700">Last Name</label>
required <input
value={formData.firstName} type="text"
onChange={e => setFormData({ ...formData, firstName: e.target.value })} required
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" value={formData.lastName}
/> onChange={e => setFormData({ ...formData, lastName: e.target.value })}
</div> 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> />
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> </div>
<input <div>
type="text" <label className="block text-sm font-medium text-gray-700">Role</label>
required <select
value={formData.lastName} value={formData.role}
onChange={e => setFormData({ ...formData, lastName: e.target.value })} onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
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="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> <option value="USER">User</option>
<div> <option value="MANAGER">Manager</option>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label> <option value="ADMIN">Admin</option>
<select <option value="VIEWER">Viewer</option>
value={formData.role} </select>
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} </div>
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>
> <label className="block text-sm font-medium text-gray-700">Organization</label>
<option value="USER">{t('roles.USER')}</option> <select
<option value="MANAGER">{t('roles.MANAGER')}</option> required
<option value="ADMIN">{t('roles.ADMIN')}</option> value={formData.organizationId}
<option value="VIEWER">{t('roles.VIEWER')}</option> onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
</select> 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> >
<div> <option value="">Select Organization</option>
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label> {organizations.map(org => (
<select <option key={org.id} value={org.id}>
required {org.name}
value={formData.organizationId} </option>
onChange={e => setFormData({ ...formData, organizationId: 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" </select>
> </div>
<option value="">{t('modal.selectOrganization')}</option> <div>
{organizations.map(org => ( <label className="block text-sm font-medium text-gray-700">
<option key={org.id} value={org.id}> Password (leave empty for auto-generated)
{org.name} </label>
</option> <input
))} type="password"
</select> value={formData.password}
</div> onChange={e => setFormData({ ...formData, password: e.target.value })}
<div> 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"
<label className="block text-sm font-medium text-gray-700"> />
{t('modal.password')} </div>
</label> <div className="flex justify-end space-x-2 pt-4">
<input <button
type="password" type="button"
value={formData.password} onClick={() => {
onChange={e => setFormData({ ...formData, password: e.target.value })} setShowCreateModal(false);
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" resetForm();
/> }}
</div> className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
<div className="flex justify-end space-x-2 pt-4"> >
<button Cancel
type="button" </button>
onClick={() => { <button
setShowCreateModal(false); type="submit"
resetForm(); className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
}} >
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50" Create
> </button>
{t('modal.cancel')} </div>
</button> </form>
<button </div>
type="submit" </div>
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" )}
>
{t('modal.create')} {/* Edit Modal */}
</button> {showEditModal && selectedUser && (
</div> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
</form> <div className="bg-white rounded-lg p-6 max-w-md w-full">
</div> <h2 className="text-xl font-bold mb-4">Edit User</h2>
</div> <form onSubmit={handleUpdate} className="space-y-4">
)} <div>
<label className="block text-sm font-medium text-gray-700">Email (read-only)</label>
{/* Edit Modal */} <input
{showEditModal && selectedUser && ( type="email"
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> disabled
<div className="bg-white rounded-lg p-6 max-w-md w-full"> value={formData.email}
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2> className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-gray-100 rounded-md shadow-sm"
<form onSubmit={handleUpdate} className="space-y-4"> />
<div> </div>
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label> <div>
<input <label className="block text-sm font-medium text-gray-700">First Name</label>
type="email" <input
disabled type="text"
value={formData.email} required
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-gray-100 rounded-md shadow-sm" value={formData.firstName}
/> onChange={e => setFormData({ ...formData, firstName: e.target.value })}
</div> 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> />
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> </div>
<input <div>
type="text" <label className="block text-sm font-medium text-gray-700">Last Name</label>
required <input
value={formData.firstName} type="text"
onChange={e => setFormData({ ...formData, firstName: e.target.value })} required
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" value={formData.lastName}
/> onChange={e => setFormData({ ...formData, lastName: e.target.value })}
</div> 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> />
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> </div>
<input <div>
type="text" <label className="block text-sm font-medium text-gray-700">Role</label>
required <select
value={formData.lastName} value={formData.role}
onChange={e => setFormData({ ...formData, lastName: e.target.value })} onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
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="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> <option value="USER">User</option>
<div> <option value="MANAGER">Manager</option>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label> <option value="ADMIN">Admin</option>
<select <option value="VIEWER">Viewer</option>
value={formData.role} </select>
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} </div>
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="flex justify-end space-x-2 pt-4">
> <button
<option value="USER">{t('roles.USER')}</option> type="button"
<option value="MANAGER">{t('roles.MANAGER')}</option> onClick={() => {
<option value="ADMIN">{t('roles.ADMIN')}</option> setShowEditModal(false);
<option value="VIEWER">{t('roles.VIEWER')}</option> setSelectedUser(null);
</select> resetForm();
</div> }}
<div className="flex justify-end space-x-2 pt-4"> className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
<button >
type="button" Cancel
onClick={() => { </button>
setShowEditModal(false); <button
setSelectedUser(null); type="submit"
resetForm(); className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
}} >
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50" Update
> </button>
{t('modal.cancel')} </div>
</button> </form>
<button </div>
type="submit" </div>
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" )}
>
{t('modal.update')} {/* Delete Confirmation */}
</button> {showDeleteConfirm && selectedUser && (
</div> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
</form> <div className="bg-white rounded-lg p-6 max-w-md w-full">
</div> <h2 className="text-xl font-bold mb-4 text-red-600">Confirm Delete</h2>
</div> <p className="text-gray-700 mb-6">
)} Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
This action cannot be undone.
{/* Delete Confirmation */} </p>
{showDeleteConfirm && selectedUser && ( <div className="flex justify-end space-x-2">
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <button
<div className="bg-white rounded-lg p-6 max-w-md w-full"> onClick={() => {
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2> setShowDeleteConfirm(false);
<p className="text-gray-700 mb-6"> setSelectedUser(null);
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })} }}
</p> className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
<div className="flex justify-end space-x-2"> >
<button Cancel
onClick={() => { </button>
setShowDeleteConfirm(false); <button
setSelectedUser(null); onClick={handleDelete}
}} className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50" >
> Delete
{t('deleteConfirm.cancel')} </button>
</button> </div>
<button </div>
onClick={handleDelete} </div>
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700" )}
> </div>
{t('deleteConfirm.confirm')} );
</button> }
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -21,12 +21,9 @@ interface BookingForm {
volumeCBM: number; volumeCBM: number;
weightKG: number; weightKG: number;
palletCount: number; palletCount: number;
freightTotal: number; priceUSD: number;
freightCurrency: string; priceEUR: number;
fobTotal: number; primaryCurrency: 'USD' | 'EUR';
fobCurrency: string;
primaryCurrency: string;
totalPriceForSorting: number;
transitDays: number; transitDays: number;
containerType: string; containerType: string;
@ -64,12 +61,9 @@ function NewBookingPageContent() {
volumeCBM: 0, volumeCBM: 0,
weightKG: 0, weightKG: 0,
palletCount: 0, palletCount: 0,
freightTotal: 0, priceUSD: 0,
freightCurrency: 'USD', priceEUR: 0,
fobTotal: 0, primaryCurrency: 'EUR',
fobCurrency: 'EUR',
primaryCurrency: 'USD',
totalPriceForSorting: 0,
transitDays: 0, transitDays: 0,
containerType: '', containerType: '',
documents: [], documents: [],
@ -91,12 +85,9 @@ function NewBookingPageContent() {
volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'), volumeCBM: parseFloat(searchParams.get('volumeCBM') || '0'),
weightKG: parseFloat(searchParams.get('weightKG') || '0'), weightKG: parseFloat(searchParams.get('weightKG') || '0'),
palletCount: parseInt(searchParams.get('palletCount') || '0'), palletCount: parseInt(searchParams.get('palletCount') || '0'),
freightTotal: rateData.priceBreakdown.totalFreight, priceUSD: rateData.priceUSD,
freightCurrency: rateData.priceBreakdown.freightCurrency, priceEUR: rateData.priceEUR,
fobTotal: rateData.priceBreakdown.totalFob, primaryCurrency: rateData.primaryCurrency as 'USD' | 'EUR',
fobCurrency: rateData.priceBreakdown.fobCurrency,
primaryCurrency: rateData.priceBreakdown.primaryCurrency || 'USD',
totalPriceForSorting: rateData.priceBreakdown.totalPriceForSorting,
transitDays: rateData.transitDays, transitDays: rateData.transitDays,
containerType: rateData.containerType, containerType: rateData.containerType,
})); }));
@ -160,14 +151,6 @@ function NewBookingPageContent() {
// Create FormData for multipart upload // Create FormData for multipart upload
const formDataToSend = new FormData(); const formDataToSend = new FormData();
// Map price breakdown to backend-expected fields — sum all charges per currency
const priceUSD =
(formData.freightCurrency === 'USD' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'USD' ? formData.fobTotal : 0);
const priceEUR =
(formData.freightCurrency === 'EUR' ? formData.freightTotal : 0) +
(formData.fobCurrency === 'EUR' ? formData.fobTotal : 0);
// Append all booking data // Append all booking data
formDataToSend.append('carrierName', formData.carrierName); formDataToSend.append('carrierName', formData.carrierName);
formDataToSend.append('carrierEmail', formData.carrierEmail); formDataToSend.append('carrierEmail', formData.carrierEmail);
@ -176,8 +159,8 @@ function NewBookingPageContent() {
formDataToSend.append('volumeCBM', formData.volumeCBM.toString()); formDataToSend.append('volumeCBM', formData.volumeCBM.toString());
formDataToSend.append('weightKG', formData.weightKG.toString()); formDataToSend.append('weightKG', formData.weightKG.toString());
formDataToSend.append('palletCount', formData.palletCount.toString()); formDataToSend.append('palletCount', formData.palletCount.toString());
formDataToSend.append('priceUSD', priceUSD.toString()); formDataToSend.append('priceUSD', formData.priceUSD.toString());
formDataToSend.append('priceEUR', priceEUR.toString()); formDataToSend.append('priceEUR', formData.priceEUR.toString());
formDataToSend.append('primaryCurrency', formData.primaryCurrency); formDataToSend.append('primaryCurrency', formData.primaryCurrency);
formDataToSend.append('transitDays', formData.transitDays.toString()); formDataToSend.append('transitDays', formData.transitDays.toString());
formDataToSend.append('containerType', formData.containerType); formDataToSend.append('containerType', formData.containerType);
@ -363,28 +346,22 @@ function NewBookingPageContent() {
<h3 className="text-lg font-semibold text-gray-900 mb-4"> <h3 className="text-lg font-semibold text-gray-900 mb-4">
Prix estimé Prix estimé
</h3> </h3>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p> <p className="text-sm text-gray-600">Prix en EUR</p>
<p className="text-2xl font-bold text-gray-800"> <p className="text-3xl font-bold text-green-600">
{formatPrice(formData.freightTotal, formData.freightCurrency)} {formatPrice(formData.priceEUR, 'EUR')}
</p> </p>
</div> </div>
{formData.fobTotal > 0 && ( {formData.priceUSD > 0 && (
<div className="text-right"> <div className="text-right">
<p className="text-sm text-gray-600">FOB ({formData.fobCurrency})</p> <p className="text-sm text-gray-600">Prix en USD</p>
<p className="text-xl font-semibold text-gray-700"> <p className="text-xl font-semibold text-gray-700">
{formatPrice(formData.fobTotal, formData.fobCurrency)} {formatPrice(formData.priceUSD, 'USD')}
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')}
</p>
</div>
</div> </div>
</div> </div>
@ -585,24 +562,10 @@ function NewBookingPageContent() {
{formData.documents.length} fichier(s) {formData.documents.length} fichier(s)
</span> </span>
</div> </div>
<div className="flex justify-between">
<span className="text-gray-600">Fret :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</span>
</div>
{formData.fobTotal > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">FOB :</span>
<span className="font-semibold text-gray-900">
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</span>
</div>
)}
<div className="flex justify-between border-t pt-3 mt-3"> <div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span> <span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600"> <span className="text-2xl font-bold text-green-600">
{formatPrice(formData.totalPriceForSorting, formData.primaryCurrency || 'EUR')} {formatPrice(formData.priceEUR, 'EUR')}
</span> </span>
</div> </div>
</div> </div>

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