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();
} }
/**
* 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
);
}
/**
* Check if pallet count matches
* 0 means "any pallet count" (flexible)
* Otherwise must match exactly or be within range
*/
matchesPalletCount(palletCount: number): boolean {
// If rate has 0 pallets, it's flexible
if (this.palletCount === 0) {
return true;
}
// Otherwise must match exactly
return this.palletCount === palletCount;
}
/**
* Check if rate matches a specific route
*/
matchesRoute(origin: PortCode, destination: PortCode): boolean { matchesRoute(origin: PortCode, destination: PortCode): boolean {
return this.originCode.equals(origin) && this.destinationCode.equals(destination); return this.origin.equals(origin) && this.destination.equals(destination);
} }
isDgAccepted(): boolean { /**
return this.dgSurcharge.dgSurchargeRate !== 'NOT ACCEPTED'; * Check if rate has separate surcharges
*/
hasSurcharges(): boolean {
return !this.surcharges.isEmpty();
} }
isDgOnRequest(): boolean { /**
return this.dgSurcharge.dgSurchargeRate === 'ON REQUEST'; * Get surcharge details as formatted string
*/
getSurchargeDetails(): string {
return this.surcharges.getDetails();
} }
isDirectRoute(): boolean { /**
return this.routing.trim().toLowerCase() === 'direct'; * Check if this is an "all-in" rate (no separate surcharges)
} */
isAllInPrice(): boolean {
getFrequencyScore(): number { return this.surcharges.isEmpty();
switch (this.frequency) {
case 'Weekly':
return 4;
case 'Bi-Weekly':
return 3;
case 'Bi-Monthly':
return 2;
case 'Monthly':
return 1;
default:
return 2;
}
} }
/**
* 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,
}; };
} }
/**
* 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 [];
} }
function round2(n: number): number { const surcharges: SurchargeItem[] = [];
return Math.round(n * 100) / 100; 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 => {
if (configs.length > 0) {
const results = await Promise.allSettled(
configs.map(config => {
const email = config.metadata?.companyEmail || 'bookings@example.com'; const email = config.metadata?.companyEmail || 'bookings@example.com';
return this.csvRateLoader.loadRatesFromCsv( // Pass company name from config to override CSV column value
config.csvFilePath, return this.csvRateLoader.loadRatesFromCsv(config.csvFilePath, email, config.companyName);
email, });
config.companyName
);
})
);
const failures = results.filter(r => r.status === 'rejected'); // Use allSettled to handle missing files gracefully
const results = await Promise.allSettled(ratePromises);
const rateArrays = results
.filter(
(result): result is PromiseFulfilledResult<CsvRate[]> => result.status === 'fulfilled'
)
.map(result => result.value);
// Log any failed file loads
const failures = results.filter(result => result.status === 'rejected');
if (failures.length > 0) { if (failures.length > 0) {
console.warn(`Failed to load ${failures.length} CSV files from database configs`); console.warn(
`Failed to load ${failures.length} CSV files:`,
failures.map(
(f, idx) => `${configs[idx]?.csvFilePath}: ${(f as PromiseRejectedResult).reason}`
)
);
} }
return results return rateArrays.flat();
.filter((r): r is PromiseFulfilledResult<CsvRate[]> => r.status === 'fulfilled')
.flatMap(r => r.value);
}
// DB has no active configs — fall through to local CSV files
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);
});
}); });
it('RAPID transit always < ECONOMIC transit for different base days', () => { describe('Validation de la logique métier', () => {
for (const days of [5, 10, 20, 30, 60]) { it('RAPID doit TOUJOURS être plus cher que ECONOMIC', () => {
const rate = { ...mockRate, transitDays: days } as any; // 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;
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(
try { `⚠️ Failed to list MinIO files: ${minioError.message}. Falling back to local files.`
await fs.access(this.csvDirectory);
} catch {
return [];
}
const files = await fs.readdir(this.csvDirectory);
return files.filter(f => f.endsWith('.csv'));
} catch {
return [];
}
}
private resolvePath(filePath: string): string {
return path.isAbsolute(filePath) ? filePath : path.join(this.csvDirectory, filePath);
}
private validateCsvStructure(records: CsvRow[]): void {
if (records.length === 0) throw new Error('CSV file is empty');
const firstRecord = records[0];
const missing = REQUIRED_COLUMNS.filter(col => !(col in firstRecord));
if (missing.length > 0) {
throw new Error(`Missing required columns: ${missing.join(', ')}`);
}
}
private mapToCsvRate(r: CsvRow, companyEmail: string, companyNameOverride?: string): CsvRate {
const companyName = companyNameOverride || r.companyName.trim();
// Admin-configured email always takes priority over the value in the CSV row
const email = companyEmail?.trim() || r.companyEmail?.trim();
const freight: FreightPricing = {
freightCurrency: r.freightCurrency.toUpperCase(),
freightRatePerCBM: parseFloat(r.freightRatePerCBM) || 0,
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 frequency = parseFrequency(r.frequency);
return new CsvRate(
companyName,
email,
r.originCFS.trim(),
PortCode.create(r.originCode.trim()),
r.portOfLoading.trim(),
r.routing.trim(),
r.destinationCFS.trim(),
PortCode.create(r.destinationCode.trim()),
r.destinationCountry.trim(),
ContainerType.create(r.containerType.trim()),
freight,
fob,
dgSurcharge,
r.remarks?.trim() || '',
frequency,
parseInt(r.transitDays, 10),
validity
); );
} }
} }
function parseDgValue(raw: string): DgSurchargeValue { // Fallback: list from local file system
if (!raw || raw.trim() === '') return 0; try {
const upper = raw.trim().toUpperCase(); await fs.access(this.csvDirectory);
if (upper === 'ON REQUEST') return 'ON REQUEST'; } catch {
if (upper === 'NOT ACCEPTED') return 'NOT ACCEPTED'; this.logger.warn(`CSV directory does not exist: ${this.csvDirectory}`);
const num = parseFloat(raw); return [];
return isNaN(num) ? 0 : num;
} }
function parseFrequency(raw: string): FrequencyType { const files = await fs.readdir(this.csvDirectory);
switch (raw?.trim()) { return files.filter(file => file.endsWith('.csv'));
case 'Weekly': } catch (error) {
return 'Weekly'; const errorMessage = error instanceof Error ? error.message : String(error);
case 'Bi-Weekly': this.logger.error(`Failed to list CSV files: ${errorMessage}`);
return 'Bi-Weekly'; return [];
case 'Bi-Monthly': }
return 'Bi-Monthly'; }
case 'Monthly':
return 'Monthly'; /**
default: * Validate that CSV has all required columns
return 'Weekly'; */
private validateCsvStructure(records: CsvRow[]): void {
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 missingColumns = requiredColumns.filter(col => !(col in firstRecord));
if (missingColumns.length > 0) {
throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
}
}
/**
* Map CSV row to CsvRate domain entity
*/
private mapToCsvRate(
record: CsvRow,
companyEmail: string,
companyNameOverride?: string
): CsvRate {
// Parse surcharges
const surcharges = this.parseSurcharges(record);
// Create DateRange
const validFrom = new Date(record.validFrom);
const validUntil = new Date(record.validUntil);
const validity = DateRange.create(validFrom, validUntil, true);
// Use override company name if provided, otherwise use the one from CSV
const companyName = companyNameOverride || record.companyName.trim();
// Create CsvRate
return new CsvRate(
companyName,
companyEmail,
PortCode.create(record.origin),
PortCode.create(record.destination),
ContainerType.create(record.containerType),
{
minCBM: parseFloat(record.minVolumeCBM),
maxCBM: parseFloat(record.maxVolumeCBM),
},
{
minKG: parseFloat(record.minWeightKG),
maxKG: parseFloat(record.maxWeightKG),
},
parseInt(record.palletCount, 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
);
}
/**
* Parse surcharges from CSV row
*/
private parseSurcharges(record: CsvRow): Surcharge[] {
const hasSurcharges = record.hasSurcharges.toLowerCase() === 'true';
if (!hasSurcharges) {
return [];
}
const surcharges: Surcharge[] = [];
const currency = record.currency.toUpperCase();
// BAF (Bunker Adjustment Factor)
if (record.surchargeBAF && parseFloat(record.surchargeBAF) > 0) {
surcharges.push(
new Surcharge(
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,8 +1,7 @@
'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,
@ -14,42 +13,10 @@ import {
Linkedin, Linkedin,
Calendar, Calendar,
ArrowRight, ArrowRight,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type ValueKey = 'excellence' | 'transparency' | 'collaboration' | 'innovation';
type TeamKey = 'ceo' | 'cto' | 'coo' | 'vpSales' | 'vpEng' | 'vpProduct';
type TimelineKey = '2023' | '2024' | '2025' | '2026';
type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings';
const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [
{ key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' },
{ key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' },
{ key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' },
{ key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' },
];
const TEAM: { key: TeamKey; name: string; linkedin: string }[] = [
{ key: 'ceo', name: 'Jean-Pierre Durand', linkedin: '#' },
{ key: 'cto', name: 'Marie Lefebvre', linkedin: '#' },
{ key: 'coo', name: 'Thomas Martin', linkedin: '#' },
{ key: 'vpSales', name: 'Sophie Bernard', linkedin: '#' },
{ key: 'vpEng', name: 'Alexandre Petit', linkedin: '#' },
{ key: 'vpProduct', name: 'Claire Moreau', linkedin: '#' },
];
const TIMELINE_YEARS: TimelineKey[] = ['2023', '2024', '2025', '2026'];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'clients', value: '500+' },
{ key: 'carriers', value: '50+' },
{ key: 'countries', value: '15' },
{ key: 'bookings', value: '100K+' },
];
export default function AboutPage() { export default function AboutPage() {
const t = useTranslations('marketing.about');
const heroRef = useRef(null); const heroRef = useRef(null);
const missionRef = useRef(null); const missionRef = useRef(null);
const valuesRef = useRef(null); const valuesRef = useRef(null);
@ -64,6 +31,117 @@ export default function AboutPage() {
const isTimelineInView = useInView(timelineRef, { once: true }); const isTimelineInView = useInView(timelineRef, { once: true });
const isStatsInView = useInView(statsRef, { once: true }); const isStatsInView = useInView(statsRef, { once: true });
const values = [
{
icon: Target,
title: 'Excellence',
description:
'Nous visons l\'excellence dans chaque aspect de notre plateforme, en offrant une expérience utilisateur de premier ordre.',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Heart,
title: 'Transparence',
description:
'Nous croyons en une communication ouverte et honnête avec nos clients, partenaires et employés.',
color: 'from-pink-500 to-rose-500',
},
{
icon: Users,
title: 'Collaboration',
description:
'Le succès se construit ensemble. Nous travaillons main dans la main avec nos clients pour atteindre leurs objectifs.',
color: 'from-purple-500 to-indigo-500',
},
{
icon: TrendingUp,
title: 'Innovation',
description:
'Nous repoussons constamment les limites de la technologie pour révolutionner le fret maritime.',
color: 'from-orange-500 to-amber-500',
},
];
const team = [
{
name: 'Jean-Pierre Durand',
role: 'CEO & Co-fondateur',
bio: 'Ex-directeur chez Maersk, 20 ans d\'expérience dans le shipping',
image: '/assets/images/team/ceo.jpg',
linkedin: '#',
},
{
name: 'Marie Lefebvre',
role: 'CTO & Co-fondatrice',
bio: 'Ex-Google, experte en plateformes B2B et systèmes distribués',
image: '/assets/images/team/cto.jpg',
linkedin: '#',
},
{
name: 'Thomas Martin',
role: 'COO',
bio: 'Ex-CMA CGM, spécialiste des opérations maritimes internationales',
image: '/assets/images/team/coo.jpg',
linkedin: '#',
},
{
name: 'Sophie Bernard',
role: 'VP Sales',
bio: '15 ans d\'expérience commerciale dans le secteur logistique',
image: '/assets/images/team/vp-sales.jpg',
linkedin: '#',
},
{
name: 'Alexandre Petit',
role: 'VP Engineering',
bio: 'Ex-Uber Freight, expert en systèmes de réservation temps réel',
image: '/assets/images/team/vp-eng.jpg',
linkedin: '#',
},
{
name: 'Claire Moreau',
role: 'VP Product',
bio: 'Ex-Flexport, passionnée par l\'UX et l\'innovation produit',
image: '/assets/images/team/vp-product.jpg',
linkedin: '#',
},
];
const timeline = [
{
year: '2021',
title: 'Fondation',
description: 'Création de Xpeditis avec une vision claire : simplifier le fret maritime pour tous.',
},
{
year: '2022',
title: 'Première version',
description: 'Lancement de la plateforme beta avec 10 compagnies maritimes partenaires.',
},
{
year: '2023',
title: 'Série A',
description: 'Levée de fonds de 15M€ pour accélérer notre expansion européenne.',
},
{
year: '2024',
title: 'Expansion',
description: '50+ compagnies maritimes, présence dans 15 pays européens.',
},
{
year: '2025',
title: 'Leader européen',
description: 'Plateforme #1 du fret maritime B2B en Europe avec 500+ clients actifs.',
},
];
const stats = [
{ value: '500+', label: 'Clients actifs' },
{ value: '50+', label: 'Compagnies maritimes' },
{ value: '15', label: 'Pays couverts' },
{ value: '100K+', label: 'Réservations/an' },
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
visible: { visible: {
@ -110,19 +188,21 @@ export default function AboutPage() {
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" 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"
> >
<Ship className="w-5 h-5 text-brand-turquoise" /> <Ship className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Notre histoire</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Révolutionner le fret maritime,
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} une réservation à la fois
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Fondée en 2021, Xpeditis est née d'une vision simple : rendre le fret maritime aussi simple
qu'une réservation de vol. Nous connectons les transitaires du monde entier avec les plus
grandes compagnies maritimes.
</p> </p>
</motion.div> </motion.div>
</div> </div>
@ -154,9 +234,11 @@ export default function AboutPage() {
<div className="w-16 h-16 bg-brand-turquoise rounded-2xl flex items-center justify-center mb-6"> <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" /> <Target className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Mission</h2>
<p className="text-gray-600 text-lg leading-relaxed"> <p className="text-gray-600 text-lg leading-relaxed">
{t('mission.body')} Démocratiser l'accès au fret maritime en offrant une plateforme technologique de pointe
qui simplifie la recherche, la comparaison et la réservation de transport maritime pour
tous les professionnels de la logistique.
</p> </p>
</motion.div> </motion.div>
@ -167,9 +249,11 @@ export default function AboutPage() {
<div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6"> <div className="w-16 h-16 bg-brand-green rounded-2xl flex items-center justify-center mb-6">
<Eye className="w-8 h-8 text-white" /> <Eye className="w-8 h-8 text-white" />
</div> </div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-4">Notre Vision</h2>
<p className="text-gray-600 text-lg leading-relaxed"> <p className="text-gray-600 text-lg leading-relaxed">
{t('vision.body')} Devenir la référence mondiale du fret maritime digital, en connectant chaque transitaire
à chaque compagnie maritime, partout dans le monde, avec la transparence et l'efficacité
que mérite le commerce international.
</p> </p>
</motion.div> </motion.div>
</motion.div> </motion.div>
@ -185,9 +269,9 @@ export default function AboutPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => ( {stats.map((stat, index) => (
<motion.div <motion.div
key={stat.key} key={index}
variants={itemVariants} variants={itemVariants}
className="text-center" className="text-center"
> >
@ -199,7 +283,7 @@ export default function AboutPage() {
> >
{stat.value} {stat.value}
</motion.div> </motion.div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div> <div className="text-gray-600 font-medium">{stat.label}</div>
</motion.div> </motion.div>
))} ))}
</div> </div>
@ -215,9 +299,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('valuesTitle')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Nos Valeurs</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('valuesSubtitle')} Les principes qui guident chacune de nos décisions
</p> </p>
</motion.div> </motion.div>
@ -227,11 +311,11 @@ export default function AboutPage() {
animate={isValuesInView ? 'visible' : 'hidden'} animate={isValuesInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
> >
{VALUES.map((value) => { {values.map((value, index) => {
const IconComponent = value.icon; const IconComponent = value.icon;
return ( return (
<motion.div <motion.div
key={value.key} key={index}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -10 }} whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all" className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -241,8 +325,8 @@ export default function AboutPage() {
> >
<IconComponent className="w-7 h-7 text-white" /> <IconComponent className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`values.${value.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">{value.title}</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p> <p className="text-gray-600">{value.description}</p>
</motion.div> </motion.div>
); );
})} })}
@ -259,13 +343,14 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('timelineTitle')}</h2> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">Notre Parcours</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('timelineSubtitle')} De la startup au leader européen du fret maritime digital
</p> </p>
</motion.div> </motion.div>
<div className="relative"> <div className="relative">
{/* 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"> <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">
<motion.div <motion.div
initial={{ scaleY: 0 }} initial={{ scaleY: 0 }}
@ -277,9 +362,9 @@ export default function AboutPage() {
</div> </div>
<div className="space-y-12"> <div className="space-y-12">
{TIMELINE_YEARS.map((year, index) => ( {timeline.map((item, index) => (
<motion.div <motion.div
key={year} key={index}
initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }} initial={{ opacity: 0, x: index % 2 === 0 ? -64 : 64 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, amount: 0.4 }} viewport={{ once: true, amount: 0.4 }}
@ -290,13 +375,14 @@ export default function AboutPage() {
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow"> <div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}> <div className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}>
<Calendar className="w-5 h-5 text-brand-turquoise" /> <Calendar className="w-5 h-5 text-brand-turquoise" />
<span className="text-2xl font-bold text-brand-turquoise">{year}</span> <span className="text-2xl font-bold text-brand-turquoise">{item.year}</span>
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`timeline.${year}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">{item.title}</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p> <p className="text-gray-600">{item.description}</p>
</div> </div>
</div> </div>
{/* Animated center dot */}
<div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0"> <div className="hidden lg:flex items-center justify-center mx-4 flex-shrink-0">
<motion.div <motion.div
initial={{ scale: 0 }} initial={{ scale: 0 }}
@ -316,7 +402,7 @@ export default function AboutPage() {
</section> </section>
{/* Team Section */} {/* Team Section */}
<section ref={teamRef} className="py-20" style={{ display: 'none' }}> <section ref={teamRef} className="py-20">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@ -324,9 +410,9 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('teamTitle')}</h2> <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"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('teamSubtitle')} Des experts passionnés par le maritime et la technologie
</p> </p>
</motion.div> </motion.div>
@ -336,9 +422,9 @@ export default function AboutPage() {
animate={isTeamInView ? 'visible' : 'hidden'} animate={isTeamInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{TEAM.map((member) => ( {team.map((member, index) => (
<motion.div <motion.div
key={member.key} key={index}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -10 }} whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group" className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
@ -358,8 +444,8 @@ export default function AboutPage() {
</div> </div>
<div className="p-6"> <div className="p-6">
<h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3> <h3 className="text-xl font-bold text-brand-navy mb-1">{member.name}</h3>
<p className="text-brand-turquoise font-medium mb-3">{t(`team.${member.key}.role`)}</p> <p className="text-brand-turquoise font-medium mb-3">{member.role}</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p> <p className="text-gray-600 text-sm">{member.bio}</p>
</div> </div>
</motion.div> </motion.div>
))} ))}
@ -377,24 +463,25 @@ export default function AboutPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cta.title')} Rejoignez l'aventure Xpeditis
</h2> </h2>
<p className="text-xl text-white/80 mb-10"> <p className="text-xl text-white/80 mb-10">
{t('cta.body')} 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> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
<Link <Link
href="/register" 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" 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>{t('cta.createAccount')}</span> <span>Créer un compte</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
<Link <Link
href="/careers" href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg" className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
> >
{t('cta.viewCareers')} Voir les offres d'emploi
</Link> </Link>
</div> </div>
</motion.div> </motion.div>

View File

@ -1,8 +1,7 @@
'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,
@ -16,34 +15,11 @@ import {
Globe, Globe,
FileText, FileText,
Anchor, Anchor,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type CategoryKey = 'all' | 'industry' | 'technology' | 'guides' | 'news';
type ArticleKey = 'incoterms' | 'costs' | 'ports' | 'funding' | 'green' | 'api' | 'documents';
const CATEGORIES: { key: CategoryKey; icon: LucideIcon }[] = [
{ key: 'all', icon: BookOpen },
{ key: 'industry', icon: Ship },
{ key: 'technology', icon: TrendingUp },
{ key: 'guides', icon: FileText },
{ key: 'news', icon: Globe },
];
const ARTICLES: { id: number; key: ArticleKey; category: Exclude<CategoryKey, 'all'>; tags: string[] }[] = [
{ id: 2, key: 'incoterms', category: 'guides', tags: ['Incoterms', 'Guide', 'Commerce'] },
{ id: 3, key: 'costs', category: 'guides', tags: ['Optimisation', 'Costs', 'Strategy'] },
{ id: 4, key: 'ports', category: 'industry', tags: ['Ports', 'Europe', 'Stats'] },
{ id: 5, key: 'funding', category: 'news', tags: ['Funding', 'Growth', 'Xpeditis'] },
{ id: 6, key: 'green', category: 'industry', tags: ['Environment', 'Decarbonization', 'Sustainability'] },
{ id: 7, key: 'api', category: 'technology', tags: ['API', 'Integration', 'Technical'] },
{ id: 8, key: 'documents', category: 'guides', tags: ['Documents', 'Export', 'Customs'] },
];
export default function BlogPage() { export default function BlogPage() {
const t = useTranslations('marketing.blog'); const [selectedCategory, setSelectedCategory] = useState('all');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const heroRef = useRef(null); const heroRef = useRef(null);
@ -54,14 +30,121 @@ export default function BlogPage() {
const isArticlesInView = useInView(articlesRef, { once: true }); const isArticlesInView = useInView(articlesRef, { once: true });
const isCategoriesInView = useInView(categoriesRef, { once: true }); const isCategoriesInView = useInView(categoriesRef, { once: true });
const filteredArticles = ARTICLES.filter((article) => { const categories = [
{ value: 'all', label: 'Tous les articles', icon: BookOpen },
{ value: 'industry', label: 'Industrie maritime', icon: Ship },
{ value: 'technology', label: 'Technologie', icon: TrendingUp },
{ value: 'guides', label: 'Guides pratiques', icon: FileText },
{ value: 'news', label: 'Actualités', icon: Globe },
];
const featuredArticle = {
id: 1,
title: 'L\'avenir du fret maritime : comment l\'IA transforme la logistique',
excerpt:
'Découvrez comment l\'intelligence artificielle révolutionne la gestion des expéditions maritimes et optimise les chaînes d\'approvisionnement mondiales.',
category: 'technology',
author: 'Marie Lefebvre',
authorRole: 'CTO',
date: '15 janvier 2025',
readTime: '8 min',
image: '/assets/images/blog/featured.jpg',
tags: ['IA', 'Innovation', 'Logistique'],
};
const articles = [
{
id: 2,
title: 'Guide complet des Incoterms 2020 pour le transport maritime',
excerpt:
'Tout ce que vous devez savoir sur les règles Incoterms et leur application dans le fret maritime international.',
category: 'guides',
author: 'Thomas Martin',
date: '10 janvier 2025',
readTime: '12 min',
image: '/assets/images/blog/incoterms.jpg',
tags: ['Incoterms', 'Guide', 'Commerce international'],
},
{
id: 3,
title: 'Comment optimiser vos coûts de transport maritime en 2025',
excerpt:
'Stratégies et conseils pratiques pour réduire vos dépenses logistiques sans compromettre la qualité de service.',
category: 'guides',
author: 'Sophie Bernard',
date: '8 janvier 2025',
readTime: '6 min',
image: '/assets/images/blog/costs.jpg',
tags: ['Optimisation', 'Coûts', 'Stratégie'],
},
{
id: 4,
title: 'Les plus grands ports européens : classement 2025',
excerpt:
'Analyse des performances des principaux ports européens et tendances du trafic conteneurisé.',
category: 'industry',
author: 'Jean-Pierre Durand',
date: '5 janvier 2025',
readTime: '10 min',
image: '/assets/images/blog/ports.jpg',
tags: ['Ports', 'Europe', 'Statistiques'],
},
{
id: 5,
title: 'Xpeditis lève 15M€ pour accélérer son expansion',
excerpt:
'Notre série A nous permet de renforcer notre équipe et d\'étendre notre présence en Europe.',
category: 'news',
author: 'Jean-Pierre Durand',
date: '3 janvier 2025',
readTime: '4 min',
image: '/assets/images/blog/funding.jpg',
tags: ['Financement', 'Croissance', 'Xpeditis'],
},
{
id: 6,
title: 'Décarbonation du transport maritime : où en sommes-nous ?',
excerpt:
'État des lieux des initiatives environnementales dans le secteur maritime et perspectives pour 2030.',
category: 'industry',
author: 'Claire Moreau',
date: '28 décembre 2024',
readTime: '9 min',
image: '/assets/images/blog/green.jpg',
tags: ['Environnement', 'Décarbonation', 'Durabilité'],
},
{
id: 7,
title: 'APIs et intégrations : comment connecter votre TMS à Xpeditis',
excerpt:
'Guide technique pour intégrer notre plateforme avec vos systèmes de gestion existants.',
category: 'technology',
author: 'Alexandre Petit',
date: '22 décembre 2024',
readTime: '15 min',
image: '/assets/images/blog/api.jpg',
tags: ['API', 'Intégration', 'Technique'],
},
{
id: 8,
title: 'Les documents essentiels pour l\'export maritime',
excerpt:
'Check-list complète des documents requis pour vos expéditions maritimes internationales.',
category: 'guides',
author: 'Thomas Martin',
date: '18 décembre 2024',
readTime: '7 min',
image: '/assets/images/blog/documents.jpg',
tags: ['Documents', 'Export', 'Douane'],
},
];
const filteredArticles = articles.filter((article) => {
const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory; const categoryMatch = selectedCategory === 'all' || article.category === selectedCategory;
const title = t(`articles.${article.key}.title` as any);
const excerpt = t(`articles.${article.key}.excerpt` as any);
const searchMatch = const searchMatch =
searchQuery === '' || searchQuery === '' ||
title.toLowerCase().includes(searchQuery.toLowerCase()) || article.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
excerpt.toLowerCase().includes(searchQuery.toLowerCase()); article.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return categoryMatch && searchMatch; return categoryMatch && searchMatch;
}); });
@ -111,19 +194,20 @@ export default function BlogPage() {
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" 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"
> >
<BookOpen className="w-5 h-5 text-brand-turquoise" /> <BookOpen className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Blog Xpeditis</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Actualités & Insights
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} du fret maritime
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Restez informé des dernières tendances du transport maritime, découvrez nos guides
pratiques et suivez l'actualité de Xpeditis.
</p> </p>
{/* Search Bar */} {/* Search Bar */}
@ -137,7 +221,7 @@ export default function BlogPage() {
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" /> <Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input <input
type="text" type="text"
placeholder={t('searchPlaceholder')} placeholder="Rechercher un article..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none" 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"
@ -167,13 +251,13 @@ export default function BlogPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="flex flex-wrap items-center justify-center gap-4"> <div className="flex flex-wrap items-center justify-center gap-4">
{CATEGORIES.map((category) => { {categories.map((category) => {
const IconComponent = category.icon; const IconComponent = category.icon;
const isActive = selectedCategory === category.key; const isActive = selectedCategory === category.value;
return ( return (
<button <button
key={category.key} key={category.value}
onClick={() => setSelectedCategory(category.key)} onClick={() => setSelectedCategory(category.value)}
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${ className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
isActive isActive
? 'bg-brand-turquoise text-white' ? 'bg-brand-turquoise text-white'
@ -181,7 +265,7 @@ export default function BlogPage() {
}`} }`}
> >
<IconComponent className="w-4 h-4" /> <IconComponent className="w-4 h-4" />
<span className="font-medium">{t(`categories.${category.key}`)}</span> <span className="font-medium">{category.label}</span>
</button> </button>
); );
})} })}
@ -198,7 +282,7 @@ export default function BlogPage() {
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<Link href="/blog/1"> <Link href={`/blog/${featuredArticle.id}`}>
<div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer"> <div className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden group cursor-pointer">
<div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" /> <div className="absolute inset-0 bg-gradient-to-r from-brand-navy via-brand-navy/80 to-transparent z-10" />
<div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center"> <div className="absolute right-0 top-0 bottom-0 w-1/2 bg-brand-turquoise/20 flex items-center justify-center">
@ -209,36 +293,36 @@ export default function BlogPage() {
<div className="max-w-2xl"> <div className="max-w-2xl">
<div className="flex items-center space-x-2 mb-4"> <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"> <span className="px-3 py-1 bg-brand-turquoise text-white text-sm font-medium rounded-full">
{t('featuredBadge')} À la une
</span> </span>
<span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full"> <span className="px-3 py-1 bg-white/20 text-white text-sm font-medium rounded-full">
{t('categories.technology')} {categories.find((c) => c.value === featuredArticle.category)?.label}
</span> </span>
</div> </div>
<h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors"> <h2 className="text-3xl lg:text-4xl font-bold text-white mb-4 group-hover:text-brand-turquoise transition-colors">
{t('featured.title')} {featuredArticle.title}
</h2> </h2>
<p className="text-lg text-white/80 mb-6">{t('featured.excerpt')}</p> <p className="text-lg text-white/80 mb-6">{featuredArticle.excerpt}</p>
<div className="flex items-center space-x-6 text-white/60 text-sm"> <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 space-x-2">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
<span>{t('featured.author')}</span> <span>{featuredArticle.author}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
<span>{t('featured.date')}</span> <span>{featuredArticle.date}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{t('featured.readTime')}</span> <span>{featuredArticle.readTime}</span>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2 mt-6 text-brand-turquoise font-medium opacity-0 group-hover:opacity-100 transition-opacity"> <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> <span>Lire l'article</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</div> </div>
</div> </div>
@ -258,15 +342,15 @@ export default function BlogPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="flex items-center justify-between mb-12" className="flex items-center justify-between mb-12"
> >
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2> <h2 className="text-3xl font-bold text-brand-navy">Tous les articles</h2>
<span className="text-gray-500">{t('articlesCount', { count: filteredArticles.length })}</span> <span className="text-gray-500">{filteredArticles.length} articles</span>
</motion.div> </motion.div>
{filteredArticles.length === 0 ? ( {filteredArticles.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3> <h3 className="text-xl font-medium text-gray-600">Aucun article trouvé</h3>
<p className="text-gray-500">{t('noResults.body')}</p> <p className="text-gray-500">Essayez de modifier vos filtres ou votre recherche</p>
</div> </div>
) : ( ) : (
<motion.div <motion.div
@ -283,19 +367,17 @@ export default function BlogPage() {
<Ship className="w-16 h-16 text-brand-navy/20" /> <Ship className="w-16 h-16 text-brand-navy/20" />
<div className="absolute top-4 left-4"> <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"> <span className="px-3 py-1 bg-white/90 text-brand-navy text-xs font-medium rounded-full">
{t(`categories.${article.category}`)} {categories.find((c) => c.value === article.category)?.label}
</span> </span>
</div> </div>
</div> </div>
<div className="p-6 flex-1 flex flex-col"> <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"> <h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors line-clamp-2">
{t(`articles.${article.key}.title` as any)} {article.title}
</h3> </h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1"> <p className="text-gray-600 mb-4 line-clamp-2 flex-1">{article.excerpt}</p>
{t(`articles.${article.key}.excerpt` as any)}
</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
{article.tags.map((tag) => ( {article.tags.map((tag) => (
@ -313,13 +395,13 @@ export default function BlogPage() {
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center"> <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" /> <User className="w-4 h-4 text-brand-turquoise" />
</div> </div>
<span>{t(`articles.${article.key}.author` as any)}</span> <span>{article.author}</span>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<span>{t(`articles.${article.key}.date` as any)}</span> <span>{article.date}</span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>{t(`articles.${article.key}.readTime` as any)}</span> <span>{article.readTime}</span>
</span> </span>
</div> </div>
</div> </div>
@ -341,7 +423,7 @@ export default function BlogPage() {
className="text-center mt-12" 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"> <button className="px-8 py-4 bg-white border-2 border-brand-turquoise text-brand-turquoise rounded-lg hover:bg-brand-turquoise hover:text-white transition-all font-semibold">
{t('loadMore')} Charger plus d'articles
</button> </button>
</motion.div> </motion.div>
)} )}
@ -358,27 +440,28 @@ export default function BlogPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl font-bold text-white mb-6"> <h2 className="text-4xl font-bold text-white mb-6">
{t('newsletter.title')} Restez informé
</h2> </h2>
<p className="text-xl text-white/80 mb-10"> <p className="text-xl text-white/80 mb-10">
{t('newsletter.body')} Abonnez-vous à notre newsletter pour recevoir les derniers articles et actualités
du fret maritime directement dans votre boîte mail.
</p> </p>
<form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <form className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<input <input
type="email" type="email"
placeholder={t('newsletter.emailPlaceholder')} 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" 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 <button
type="submit" 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" 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>{t('newsletter.subscribe')}</span> <span>S'abonner</span>
<ArrowRight className="w-5 h-5" /> <ArrowRight className="w-5 h-5" />
</button> </button>
</form> </form>
<p className="text-white/50 text-sm mt-4"> <p className="text-white/50 text-sm mt-4">
{t('newsletter.disclaimer')} En vous inscrivant, vous acceptez notre politique de confidentialité. Désabonnement possible à tout moment.
</p> </p>
</motion.div> </motion.div>
</div> </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,8 +1,7 @@
'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, AnimatePresence } from 'framer-motion'; import { motion, useInView, AnimatePresence } from 'framer-motion';
import { import {
Briefcase, Briefcase,
@ -23,63 +22,12 @@ import {
LineChart, LineChart,
Headphones, Headphones,
Megaphone, Megaphone,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type BenefitKey = 'health' | 'remote' | 'wellbeing' | 'learning' | 'international' | 'stockOptions';
type StatKey = 'employees' | 'nationalities' | 'offices' | 'womenTech';
type CultureKey = 'item1' | 'item2' | 'item3' | 'item4';
type JobKey = 'frontend' | 'backend' | 'pm' | 'ae' | 'csm' | 'data';
type DepartmentValue = 'all' | 'Engineering' | 'Product' | 'Sales' | 'Customer Success' | 'Data';
type LocationValue = 'all' | 'Paris' | 'Rotterdam' | 'Hambourg';
const BENEFITS: { key: BenefitKey; icon: LucideIcon }[] = [
{ key: 'health', icon: Heart },
{ key: 'remote', icon: Plane },
{ key: 'wellbeing', icon: Coffee },
{ key: 'learning', icon: GraduationCap },
{ key: 'international', icon: Users },
{ key: 'stockOptions', icon: Zap },
];
const STATS: { key: StatKey; value: string }[] = [
{ key: 'employees', value: '50+' },
{ key: 'nationalities', value: '15' },
{ key: 'offices', value: '3' },
{ key: 'womenTech', value: '40%' },
];
const CULTURE_ITEMS: CultureKey[] = ['item1', 'item2', 'item3', 'item4'];
type JobRecord = {
id: number;
key: JobKey;
department: Exclude<DepartmentValue, 'all'>;
location: Exclude<LocationValue, 'all'>;
type: string;
remote: boolean;
salary: string;
icon: LucideIcon;
};
const JOBS: JobRecord[] = [
{ id: 1, key: 'frontend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '65K - 85K €', icon: Code },
{ id: 2, key: 'backend', department: 'Engineering', location: 'Paris', type: 'CDI', remote: true, salary: '55K - 75K €', icon: Code },
{ id: 3, key: 'pm', department: 'Product', location: 'Paris', type: 'CDI', remote: true, salary: '60K - 80K €', icon: LineChart },
{ id: 4, key: 'ae', department: 'Sales', location: 'Rotterdam', type: 'CDI', remote: false, salary: '50K - 70K € + variable', icon: Megaphone },
{ id: 5, key: 'csm', department: 'Customer Success', location: 'Paris', type: 'CDI', remote: true, salary: '45K - 60K €', icon: Headphones },
{ id: 6, key: 'data', department: 'Data', location: 'Hambourg', type: 'CDI', remote: true, salary: '50K - 65K €', icon: LineChart },
];
const DEPARTMENT_VALUES: DepartmentValue[] = ['all', 'Engineering', 'Product', 'Sales', 'Customer Success', 'Data'];
const LOCATION_VALUES: LocationValue[] = ['all', 'Paris', 'Rotterdam', 'Hambourg'];
const JOB_REQ_KEYS = ['req1', 'req2', 'req3', 'req4'] as const;
export default function CareersPage() { export default function CareersPage() {
const t = useTranslations('marketing.careers'); const [selectedDepartment, setSelectedDepartment] = useState('all');
const [selectedDepartment, setSelectedDepartment] = useState<DepartmentValue>('all'); const [selectedLocation, setSelectedLocation] = useState('all');
const [selectedLocation, setSelectedLocation] = useState<LocationValue>('all');
const [expandedJob, setExpandedJob] = useState<number | null>(null); const [expandedJob, setExpandedJob] = useState<number | null>(null);
const heroRef = useRef(null); const heroRef = useRef(null);
@ -92,7 +40,161 @@ export default function CareersPage() {
const isJobsInView = useInView(jobsRef, { once: true }); const isJobsInView = useInView(jobsRef, { once: true });
const isCultureInView = useInView(cultureRef, { once: true }); const isCultureInView = useInView(cultureRef, { once: true });
const filteredJobs = JOBS.filter((job) => { const benefits = [
{
icon: Heart,
title: 'Mutuelle Premium',
description: 'Couverture santé complète pour vous et votre famille',
},
{
icon: Plane,
title: 'Télétravail Flexible',
description: 'Travaillez d\'où vous voulez, jusqu\'à 3 jours par semaine',
},
{
icon: Coffee,
title: 'Bien-être au Travail',
description: 'Salle de sport, fruits frais, et événements d\'équipe',
},
{
icon: GraduationCap,
title: 'Formation Continue',
description: '2 000€/an de budget formation et conférences',
},
{
icon: Users,
title: 'Équipe Internationale',
description: 'Travaillez avec des talents de 15 nationalités',
},
{
icon: Zap,
title: 'Stock Options',
description: 'Participez à la croissance de l\'entreprise',
},
];
const jobs = [
{
id: 1,
title: 'Senior Frontend Engineer',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '65K - 85K €',
description: 'Rejoignez notre équipe frontend pour développer la prochaine génération de notre plateforme.',
requirements: [
'5+ ans d\'expérience en développement frontend',
'Maîtrise de React, TypeScript et Next.js',
'Expérience avec les design systems',
'Capacité à mentorer des développeurs juniors',
],
icon: Code,
},
{
id: 2,
title: 'Backend Engineer (Node.js)',
department: 'Engineering',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '55K - 75K €',
description: 'Construisez des APIs scalables pour connecter les transitaires aux compagnies maritimes.',
requirements: [
'3+ ans d\'expérience en Node.js/NestJS',
'Maîtrise de PostgreSQL et Redis',
'Connaissance des architectures microservices',
'Expérience avec Docker et Kubernetes appréciée',
],
icon: Code,
},
{
id: 3,
title: 'Product Manager',
department: 'Product',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '60K - 80K €',
description: 'Définissez la vision produit et priorisez les fonctionnalités avec notre équipe.',
requirements: [
'4+ ans d\'expérience en product management B2B',
'Expérience dans la logistique ou le shipping appréciée',
'Capacité à analyser les données et définir les KPIs',
'Excellentes compétences en communication',
],
icon: LineChart,
},
{
id: 4,
title: 'Account Executive',
department: 'Sales',
location: 'Rotterdam',
type: 'CDI',
remote: false,
salary: '50K - 70K € + variable',
description: 'Développez notre portefeuille clients aux Pays-Bas et en Belgique.',
requirements: [
'3+ ans d\'expérience en vente B2B',
'Connaissance du secteur maritime/logistique',
'Maîtrise du néerlandais et de l\'anglais',
'Capacité à gérer des cycles de vente longs',
],
icon: Megaphone,
},
{
id: 5,
title: 'Customer Success Manager',
department: 'Customer Success',
location: 'Paris',
type: 'CDI',
remote: true,
salary: '45K - 60K €',
description: 'Accompagnez nos clients dans l\'utilisation de la plateforme et maximisez leur satisfaction.',
requirements: [
'2+ ans d\'expérience en customer success',
'Expérience avec les outils CRM (HubSpot, Salesforce)',
'Excellent relationnel et sens du service',
'Capacité à former et accompagner les utilisateurs',
],
icon: Headphones,
},
{
id: 6,
title: 'Data Analyst',
department: 'Data',
location: 'Hambourg',
type: 'CDI',
remote: true,
salary: '50K - 65K €',
description: 'Analysez les données de shipping pour optimiser notre plateforme et nos processus.',
requirements: [
'3+ ans d\'expérience en data analysis',
'Maîtrise de SQL, Python et des outils BI',
'Expérience avec le shipping/logistics appréciée',
'Capacité à communiquer les insights aux équipes',
],
icon: LineChart,
},
];
const departments = [
{ value: 'all', label: 'Tous les départements' },
{ value: 'Engineering', label: 'Engineering' },
{ value: 'Product', label: 'Product' },
{ value: 'Sales', label: 'Sales' },
{ value: 'Customer Success', label: 'Customer Success' },
{ value: 'Data', label: 'Data' },
];
const locations = [
{ value: 'all', label: 'Toutes les villes' },
{ value: 'Paris', label: 'Paris' },
{ value: 'Rotterdam', label: 'Rotterdam' },
{ value: 'Hambourg', label: 'Hambourg' },
];
const filteredJobs = jobs.filter((job) => {
const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment; const departmentMatch = selectedDepartment === 'all' || job.department === selectedDepartment;
const locationMatch = selectedLocation === 'all' || job.location === selectedLocation; const locationMatch = selectedLocation === 'all' || job.location === selectedLocation;
return departmentMatch && locationMatch; return departmentMatch && locationMatch;
@ -144,19 +246,20 @@ export default function CareersPage() {
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" 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"
> >
<Briefcase className="w-5 h-5 text-brand-turquoise" /> <Briefcase className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Rejoignez-nous</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Construisons ensemble
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} le futur du maritime
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Rejoignez une équipe passionnée qui révolutionne le fret maritime. Des défis stimulants,
une culture bienveillante et des opportunités de croissance uniques vous attendent.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6">
@ -164,14 +267,14 @@ export default function CareersPage() {
href="#jobs" href="#jobs"
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" 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>{t('viewJobs')}</span> <span>Voir les offres</span>
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</a> </a>
<Link <Link
href="/about" href="/about"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg" className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
> >
{t('learnMore')} En savoir plus
</Link> </Link>
</div> </div>
</motion.div> </motion.div>
@ -192,9 +295,14 @@ export default function CareersPage() {
<section className="py-16 bg-gray-50"> <section className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => ( {[
{ value: '50+', label: 'Employés' },
{ value: '15', label: 'Nationalités' },
{ value: '3', label: 'Bureaux en Europe' },
{ value: '40%', label: 'Femmes dans la tech' },
].map((stat, index) => (
<motion.div <motion.div
key={stat.key} key={index}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
@ -202,7 +310,7 @@ export default function CareersPage() {
className="text-center" className="text-center"
> >
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div> <div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div> <div className="text-gray-600 font-medium">{stat.label}</div>
</motion.div> </motion.div>
))} ))}
</div> </div>
@ -219,10 +327,10 @@ export default function CareersPage() {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('benefitsTitle')} Pourquoi nous rejoindre ?
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('benefitsSubtitle')} Nous investissons dans le bien-être et le développement de nos équipes
</p> </p>
</motion.div> </motion.div>
@ -232,11 +340,11 @@ export default function CareersPage() {
animate={isBenefitsInView ? 'visible' : 'hidden'} animate={isBenefitsInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
> >
{BENEFITS.map((benefit) => { {benefits.map((benefit, index) => {
const IconComponent = benefit.icon; const IconComponent = benefit.icon;
return ( return (
<motion.div <motion.div
key={benefit.key} key={index}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -5 }} whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all" className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
@ -244,8 +352,8 @@ export default function CareersPage() {
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4"> <div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" /> <IconComponent className="w-7 h-7 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t(`benefits.${benefit.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-2">{benefit.title}</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p> <p className="text-gray-600">{benefit.description}</p>
</motion.div> </motion.div>
); );
})} })}
@ -263,15 +371,21 @@ export default function CareersPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6"> <h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
{t('cultureTitle')} Notre culture
</h2> </h2>
<p className="text-xl text-white/80 mb-8"> <p className="text-xl text-white/80 mb-8">
{t('cultureBody')} Chez Xpeditis, nous croyons que les meilleures idées viennent d'équipes diverses
et inclusives. Nous valorisons l'autonomie, la créativité et le feedback constructif.
</p> </p>
<ul className="space-y-4"> <ul className="space-y-4">
{CULTURE_ITEMS.map((itemKey, index) => ( {[
'Transparence totale sur les décisions et les résultats',
'Feedback continu et culture de l\'amélioration',
'Équilibre vie pro/perso respecté',
'Célébration des succès collectifs',
].map((item, index) => (
<motion.li <motion.li
key={itemKey} key={index}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={isCultureInView ? { opacity: 1, x: 0 } : {}} animate={isCultureInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
@ -280,7 +394,7 @@ export default function CareersPage() {
<div className="w-6 h-6 bg-brand-turquoise rounded-full flex items-center justify-center flex-shrink-0"> <div className="w-6 h-6 bg-brand-turquoise rounded-full flex items-center justify-center flex-shrink-0">
<ChevronRight className="w-4 h-4 text-white" /> <ChevronRight className="w-4 h-4 text-white" />
</div> </div>
<span>{t(`culture.${itemKey}`)}</span> <span>{item}</span>
</motion.li> </motion.li>
))} ))}
</ul> </ul>
@ -315,10 +429,10 @@ export default function CareersPage() {
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4"> <h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('jobsTitle')} Nos offres d'emploi
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('jobsSubtitle')} Trouvez le poste qui correspond à vos ambitions
</p> </p>
</motion.div> </motion.div>
@ -332,12 +446,12 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedDepartment} value={selectedDepartment}
onChange={(e) => setSelectedDepartment(e.target.value as DepartmentValue)} onChange={(e) => setSelectedDepartment(e.target.value)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer" className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
> >
{DEPARTMENT_VALUES.map((value) => ( {departments.map((dept) => (
<option key={value} value={value}> <option key={dept.value} value={dept.value}>
{value === 'all' ? t('filters.allDepartments') : t(`departments.${value}` as any)} {dept.label}
</option> </option>
))} ))}
</select> </select>
@ -346,12 +460,12 @@ export default function CareersPage() {
<div className="relative"> <div className="relative">
<select <select
value={selectedLocation} value={selectedLocation}
onChange={(e) => setSelectedLocation(e.target.value as LocationValue)} onChange={(e) => setSelectedLocation(e.target.value)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer" className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
> >
{LOCATION_VALUES.map((value) => ( {locations.map((loc) => (
<option key={value} value={value}> <option key={loc.value} value={loc.value}>
{value === 'all' ? t('filters.allLocations') : t(`locations.${value}` as any)} {loc.label}
</option> </option>
))} ))}
</select> </select>
@ -369,8 +483,8 @@ export default function CareersPage() {
{filteredJobs.length === 0 ? ( {filteredJobs.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" /> <Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3> <h3 className="text-xl font-medium text-gray-600">Aucune offre trouvée</h3>
<p className="text-gray-500">{t('noJobs.body')}</p> <p className="text-gray-500">Essayez de modifier vos filtres</p>
</div> </div>
) : ( ) : (
filteredJobs.map((job) => { filteredJobs.map((job) => {
@ -393,15 +507,15 @@ export default function CareersPage() {
<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">{t(`jobs.${job.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy">{job.title}</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500"> <div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" /> <Building2 className="w-4 h-4" />
<span>{t(`departments.${job.department}` as any)}</span> <span>{job.department}</span>
</span> </span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
<span>{t(`locations.${job.location}` as any)}</span> <span>{job.location}</span>
</span> </span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
@ -414,7 +528,7 @@ export default function CareersPage() {
<div className="hidden md:flex items-center space-x-2"> <div className="hidden md:flex items-center space-x-2">
{job.remote && ( {job.remote && (
<span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full"> <span className="px-3 py-1 bg-green-100 text-green-700 text-sm font-medium rounded-full">
{t('jobCard.remote')} Remote OK
</span> </span>
)} )}
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full"> <span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
@ -440,13 +554,13 @@ export default function CareersPage() {
className="border-t border-gray-100" className="border-t border-gray-100"
> >
<div className="p-6 bg-gray-50"> <div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p> <p className="text-gray-600 mb-6">{job.description}</p>
<h4 className="font-bold text-brand-navy mb-3">{t('jobCard.profile')}</h4> <h4 className="font-bold text-brand-navy mb-3">Profil recherché :</h4>
<ul className="space-y-2 mb-6"> <ul className="space-y-2 mb-6">
{JOB_REQ_KEYS.map((reqKey) => ( {job.requirements.map((req, index) => (
<li key={reqKey} className="flex items-start space-x-2 text-gray-600"> <li key={index} className="flex items-start space-x-2 text-gray-600">
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" /> <ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span> <span>{req}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -455,11 +569,11 @@ export default function CareersPage() {
href={`/careers/${job.id}`} href={`/careers/${job.id}`}
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2" className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all font-medium flex items-center space-x-2"
> >
<span>{t('jobCard.apply')}</span> <span>Postuler</span>
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700"> <button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
{t('jobCard.learnMore')} En savoir plus
</button> </button>
</div> </div>
</div> </div>
@ -484,16 +598,17 @@ export default function CareersPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-4xl font-bold text-brand-navy mb-6"> <h2 className="text-4xl font-bold text-brand-navy mb-6">
{t('cta.title')} Pas de poste correspondant ?
</h2> </h2>
<p className="text-xl text-gray-600 mb-10"> <p className="text-xl text-gray-600 mb-10">
{t('cta.body')} Envoyez-nous une candidature spontanée ! Nous sommes toujours à la recherche de
talents passionnés pour rejoindre notre aventure.
</p> </p>
<Link <Link
href="/contact" href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg" className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"
> >
<span>{t('cta.spontaneous')}</span> <span>Candidature spontanée</span>
<ArrowRight className="w-5 h-5" /> <ArrowRight className="w-5 h-5" />
</Link> </Link>
</motion.div> </motion.div>

View File

@ -1,16 +1,13 @@
'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 { useTranslations } from 'next-intl';
import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { CheckCircle, Loader2, XCircle } from 'lucide-react';
export default function CarrierAcceptPage() { export default function CarrierAcceptPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const token = params.token as string; const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -21,18 +18,20 @@ export default function CarrierAcceptPage() {
useEffect(() => { useEffect(() => {
const acceptBooking = async () => { const acceptBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) { if (hasCalledApi.current) {
return; return;
} }
hasCalledApi.current = true; hasCalledApi.current = true;
if (!token) { if (!token) {
setError(t('common.tokenMissing')); setError('Token manquant');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
// Appeler l'API backend pour accepter le booking
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',
@ -46,17 +45,17 @@ export default function CarrierAcceptPage() {
try { try {
errorData = await response.json(); errorData = await response.json();
} catch (e) { } catch (e) {
errorData = { message: `HTTP ${response.status}` }; errorData = { message: `Erreur HTTP ${response.status}` };
} }
let errorMessage = errorData.message || t('accept.errorFallback'); let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) { if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) {
errorMessage = t('common.bookingAlreadyAccepted'); errorMessage = 'Ce booking a déjà été accepté.';
} else if (errorMessage.includes('status REJECTED')) { } else if (errorMessage.includes('status REJECTED')) {
errorMessage = t('common.bookingAlreadyRejected'); errorMessage = 'Ce booking a déjà été refusé.';
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { } else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = t('common.bookingNotFound'); errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
} }
throw new Error(errorMessage); throw new Error(errorMessage);
@ -64,6 +63,7 @@ export default function CarrierAcceptPage() {
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) {
@ -78,13 +78,13 @@ export default function CarrierAcceptPage() {
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 (
@ -92,10 +92,10 @@ export default function CarrierAcceptPage() {
<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>
@ -107,13 +107,13 @@ export default function CarrierAcceptPage() {
<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>
@ -125,27 +125,27 @@ export default function CarrierAcceptPage() {
<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

@ -2,7 +2,6 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image'; import Image from 'next/image';
import { import {
FileText, FileText,
@ -56,13 +55,13 @@ interface AccessRequirements {
status: string; status: string;
} }
const DOCUMENT_TYPE_KEYS = [ const documentTypeLabels: Record<string, string> = {
'BILL_OF_LADING', BILL_OF_LADING: 'Connaissement',
'PACKING_LIST', PACKING_LIST: 'Liste de colisage',
'COMMERCIAL_INVOICE', COMMERCIAL_INVOICE: 'Facture commerciale',
'CERTIFICATE_OF_ORIGIN', CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
'OTHER', OTHER: 'Autre document',
] as const; };
const formatFileSize = (bytes: number): string => { const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@ -81,7 +80,6 @@ const getFileIcon = (mimeType: string) => {
export default function CarrierDocumentsPage() { export default function CarrierDocumentsPage() {
const params = useParams(); const params = useParams();
const token = params.token as string; const token = params.token as string;
const t = useTranslations('carrierPortal.documents');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -98,17 +96,10 @@ export default function CarrierDocumentsPage() {
const hasCalledApi = useRef(false); const hasCalledApi = useRef(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const getDocumentTypeLabel = (type: string): string => {
if ((DOCUMENT_TYPE_KEYS as readonly string[]).includes(type)) {
return t(`documentTypes.${type}` as any);
}
return type;
};
// Check access requirements first // Check access requirements first
const checkRequirements = async () => { const checkRequirements = async () => {
if (!token) { if (!token) {
setError(t('linkInvalid')); setError('Lien invalide');
setLoading(false); setLoading(false);
return; return;
} }
@ -129,13 +120,13 @@ export default function CarrierDocumentsPage() {
try { try {
errorData = await response.json(); errorData = await response.json();
} catch { } catch {
errorData = { message: `HTTP ${response.status}` }; errorData = { message: `Erreur HTTP ${response.status}` };
} }
const errorMessage = errorData.message || t('loadError'); const errorMessage = errorData.message || 'Erreur lors du chargement';
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error(t('bookingNotFound')); throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
} }
throw new Error(errorMessage); throw new Error(errorMessage);
@ -144,12 +135,16 @@ export default function CarrierDocumentsPage() {
const reqData: AccessRequirements = await response.json(); const reqData: AccessRequirements = await response.json();
setRequirements(reqData); setRequirements(reqData);
// If booking is not accepted yet
if (reqData.status !== 'ACCEPTED') { if (reqData.status !== 'ACCEPTED') {
setError(t('notAcceptedYet')); setError(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
setLoading(false); setLoading(false);
return; return;
} }
// If no password required, fetch documents directly
if (!reqData.requiresPassword) { if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword(); await fetchDocumentsWithoutPassword();
} else { } else {
@ -157,7 +152,7 @@ export default function CarrierDocumentsPage() {
} }
} catch (err) { } catch (err) {
console.error('Error checking requirements:', err); console.error('Error checking requirements:', err);
setError(err instanceof Error ? err.message : t('loadError')); setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setLoading(false); setLoading(false);
} }
}; };
@ -177,19 +172,22 @@ export default function CarrierDocumentsPage() {
try { try {
errorData = await response.json(); errorData = await response.json();
} catch { } catch {
errorData = { message: `HTTP ${response.status}` }; errorData = { message: `Erreur HTTP ${response.status}` };
} }
const errorMessage = errorData.message || t('loadDocsError'); const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
if ( if (
errorMessage.includes('pas encore été acceptée') || errorMessage.includes('pas encore été acceptée') ||
errorMessage.includes('not accepted') errorMessage.includes('not accepted')
) { ) {
throw new Error(t('notAcceptedYet')); throw new Error(
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
);
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) { } else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error(t('bookingNotFound')); throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) { } else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
// Password is now required, show the form
setRequirements({ requiresPassword: true, status: 'ACCEPTED' }); setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false); setLoading(false);
return; return;
@ -203,7 +201,7 @@ export default function CarrierDocumentsPage() {
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
console.error('Error fetching documents:', err); console.error('Error fetching documents:', err);
setError(err instanceof Error ? err.message : t('loadError')); setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
setLoading(false); setLoading(false);
} }
}; };
@ -227,17 +225,17 @@ export default function CarrierDocumentsPage() {
try { try {
errorData = await response.json(); errorData = await response.json();
} catch { } catch {
errorData = { message: `HTTP ${response.status}` }; errorData = { message: `Erreur HTTP ${response.status}` };
} }
const errorMessage = errorData.message || t('verifyError'); const errorMessage = errorData.message || 'Erreur lors de la vérification';
if ( if (
response.status === 401 || response.status === 401 ||
errorMessage.includes('incorrect') || errorMessage.includes('incorrect') ||
errorMessage.includes('invalid') errorMessage.includes('invalid')
) { ) {
setPasswordError(t('passwordIncorrect')); setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
setVerifying(false); setVerifying(false);
return; return;
} }
@ -250,7 +248,7 @@ export default function CarrierDocumentsPage() {
setVerifying(false); setVerifying(false);
} catch (err) { } catch (err) {
console.error('Error verifying password:', err); console.error('Error verifying password:', err);
setPasswordError(err instanceof Error ? err.message : t('verifyError')); setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
setVerifying(false); setVerifying(false);
} }
}; };
@ -264,7 +262,7 @@ export default function CarrierDocumentsPage() {
const handlePasswordSubmit = (e: React.FormEvent) => { const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!password.trim()) { if (!password.trim()) {
setPasswordError(t('passwordMissing')); setPasswordError('Veuillez entrer le mot de passe');
return; return;
} }
fetchDocumentsWithPassword(password.trim()); fetchDocumentsWithPassword(password.trim());
@ -274,11 +272,13 @@ export default function CarrierDocumentsPage() {
setDownloading(doc.id); setDownloading(doc.id);
try { try {
// The downloadUrl is already a signed URL, open it directly
window.open(doc.downloadUrl, '_blank'); window.open(doc.downloadUrl, '_blank');
} catch (err) { } catch (err) {
console.error('Error downloading document:', err); console.error('Error downloading document:', err);
alert(t('downloadError')); alert('Erreur lors du téléchargement. Veuillez réessayer.');
} finally { } finally {
// Small delay to show loading state
setTimeout(() => setDownloading(null), 500); setTimeout(() => setDownloading(null), 500);
} }
}; };
@ -300,8 +300,8 @@ export default function CarrierDocumentsPage() {
<div className="min-h-screen flex items-center justify-center bg-brand-gray"> <div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" /> <Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
<p className="text-gray-600">{t('loadingHint')}</p> <p className="text-gray-600">Veuillez patienter</p>
</div> </div>
</div> </div>
); );
@ -313,13 +313,13 @@ export default function CarrierDocumentsPage() {
<div className="min-h-screen flex items-center justify-center bg-brand-gray"> <div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-xl 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('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={handleRefresh} onClick={handleRefresh}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors" className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
> >
{t('retry')} Réessayer
</button> </button>
</div> </div>
</div> </div>
@ -335,13 +335,14 @@ export default function CarrierDocumentsPage() {
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4"> <div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" /> <Lock className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
<p className="text-gray-600"> <p className="text-gray-600">
{t('password.intro')} Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
documents.
</p> </p>
{requirements.bookingNumber && ( {requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500">
{t('password.bookingLabel')} <span className="font-mono font-bold">{requirements.bookingNumber}</span> Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p> </p>
)} )}
</div> </div>
@ -349,7 +350,7 @@ export default function CarrierDocumentsPage() {
<form onSubmit={handlePasswordSubmit} className="space-y-4"> <form onSubmit={handlePasswordSubmit} className="space-y-4">
<div> <div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
{t('password.passwordLabel')} Mot de passe
</label> </label>
<div className="relative"> <div className="relative">
<input <input
@ -357,7 +358,7 @@ export default function CarrierDocumentsPage() {
id="password" id="password"
value={password} value={password}
onChange={e => setPassword(e.target.value.toUpperCase())} onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder={t('password.passwordPlaceholder')} placeholder="Ex: A3B7K9"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
@ -386,12 +387,12 @@ export default function CarrierDocumentsPage() {
{verifying ? ( {verifying ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
{t('password.verifying')} Vérification...
</> </>
) : ( ) : (
<> <>
<Lock className="w-5 h-5" /> <Lock className="w-5 h-5" />
{t('password.submit')} Accéder aux documents
</> </>
)} )}
</button> </button>
@ -399,9 +400,10 @@ export default function CarrierDocumentsPage() {
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg"> <div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800"> <p className="text-sm text-amber-800">
<strong>{t('password.helpTitle')}</strong> <strong> trouver le mot de passe ?</strong>
<br /> <br />
{t('password.helpBody')} Le mot de passe vous a é envoyé dans l'email de confirmation de la réservation. Il
correspond aux 6 derniers caractères du numéro de devis.
</p> </p>
</div> </div>
</div> </div>
@ -431,7 +433,7 @@ export default function CarrierDocumentsPage() {
onClick={handleRefresh} onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium" className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
> >
{t('header.refresh')} Actualiser
</button> </button>
</div> </div>
</header> </header>
@ -447,7 +449,7 @@ export default function CarrierDocumentsPage() {
</div> </div>
{booking.bookingNumber && ( {booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1"> <p className="text-center text-white/70 text-sm mt-1">
{t('summary.bookingNumberPrefix')} {booking.bookingNumber} N° {booking.bookingNumber}
</p> </p>
)} )}
</div> </div>
@ -456,33 +458,33 @@ export default function CarrierDocumentsPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.volume')}</p> <p className="text-xs text-gray-500">Volume</p>
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p> <p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
</div> </div>
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.weight')}</p> <p className="text-xs text-gray-500">Poids</p>
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p> <p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
</div> </div>
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.transit')}</p> <p className="text-xs text-gray-500">Transit</p>
<p className="font-semibold text-gray-900">{t('summary.transitDays', { count: booking.transitDays })}</p> <p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
</div> </div>
<div className="text-center p-3 bg-gray-50 rounded-lg"> <div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" /> <Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.type')}</p> <p className="text-xs text-gray-500">Type</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p> <p className="font-semibold text-gray-900">{booking.containerType}</p>
</div> </div>
</div> </div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm"> <div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500"> <span className="text-gray-500">
{t('summary.carrierLabel')}{' '} Transporteur:{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span> <span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span> </span>
<span className="text-gray-500"> <span className="text-gray-500">
{t('summary.refLabel')}{' '} Ref:{' '}
<span className="font-mono text-gray-900"> <span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()} {booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span> </span>
@ -496,16 +498,16 @@ export default function CarrierDocumentsPage() {
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" /> <FileText className="w-5 h-5 text-brand-turquoise" />
{t('list.heading', { count: documents.length })} Documents ({documents.length})
</h2> </h2>
</div> </div>
{documents.length === 0 ? ( {documents.length === 0 ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" /> <AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">{t('list.empty')}</p> <p className="text-gray-600">Aucun document disponible pour le moment.</p>
<p className="text-gray-500 text-sm mt-1"> <p className="text-gray-500 text-sm mt-1">
{t('list.emptyHint')} Les documents apparaîtront ici une fois ajoutés.
</p> </p>
</div> </div>
) : ( ) : (
@ -521,7 +523,7 @@ export default function CarrierDocumentsPage() {
<p className="font-medium text-gray-900">{doc.fileName}</p> <p className="font-medium text-gray-900">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full"> <span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{getDocumentTypeLabel(doc.type)} {documentTypeLabels[doc.type] || doc.type}
</span> </span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span> <span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div> </div>
@ -541,7 +543,7 @@ export default function CarrierDocumentsPage() {
) : ( ) : (
<> <>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
<span>{t('list.download')}</span> <span>Télécharger</span>
</> </>
)} )}
</button> </button>
@ -553,13 +555,13 @@ export default function CarrierDocumentsPage() {
{/* Info */} {/* Info */}
<p className="mt-6 text-center text-sm text-gray-500"> <p className="mt-6 text-center text-sm text-gray-500">
{t('footerNote')} Cette page affiche toujours les documents les plus récents de la réservation.
</p> </p>
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50"> <footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>{t('footer', { year: new Date().getFullYear() })}</p> <p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</footer> </footer>
</div> </div>
); );

View File

@ -1,16 +1,13 @@
'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 { useTranslations } from 'next-intl';
import { XCircle, Loader2, CheckCircle } from 'lucide-react'; import { XCircle, Loader2, CheckCircle } from 'lucide-react';
export default function CarrierRejectPage() { export default function CarrierRejectPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const token = params.token as string; const token = params.token as string;
const t = useTranslations('carrierPortal');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -21,18 +18,20 @@ export default function CarrierRejectPage() {
useEffect(() => { useEffect(() => {
const rejectBooking = async () => { const rejectBooking = async () => {
// Protection contre les doubles appels
if (hasCalledApi.current) { if (hasCalledApi.current) {
return; return;
} }
hasCalledApi.current = true; hasCalledApi.current = true;
if (!token) { if (!token) {
setError(t('common.tokenMissing')); setError('Token manquant');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
// Appeler l'API backend pour refuser le booking
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',
@ -46,17 +45,17 @@ export default function CarrierRejectPage() {
try { try {
errorData = await response.json(); errorData = await response.json();
} catch (e) { } catch (e) {
errorData = { message: `HTTP ${response.status}` }; errorData = { message: `Erreur HTTP ${response.status}` };
} }
let errorMessage = errorData.message || t('reject.errorFallback'); let errorMessage = errorData.message || 'Erreur lors du refus du booking';
if (errorMessage.includes('status REJECTED')) { if (errorMessage.includes('status REJECTED')) {
errorMessage = t('common.bookingAlreadyRejected'); errorMessage = 'Ce booking a déjà été refusé.';
} else if (errorMessage.includes('status ACCEPTED')) { } else if (errorMessage.includes('status ACCEPTED')) {
errorMessage = t('common.bookingAlreadyAccepted'); errorMessage = 'Ce booking a déjà été accepté.';
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) { } else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
errorMessage = t('common.bookingNotFound'); errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
} }
throw new Error(errorMessage); throw new Error(errorMessage);
@ -64,6 +63,7 @@ export default function CarrierRejectPage() {
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) {
@ -78,13 +78,13 @@ export default function CarrierRejectPage() {
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 (
@ -92,10 +92,10 @@ export default function CarrierRejectPage() {
<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>
@ -107,13 +107,13 @@ export default function CarrierRejectPage() {
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50"> <div className="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>
@ -125,27 +125,27 @@ export default function CarrierRejectPage() {
<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,7 +1,6 @@
'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,
@ -16,41 +15,86 @@ import {
Edit, Edit,
Eye, Eye,
Mail, Mail,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from '@/i18n/navigation'; import Link from 'next/link';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
type RightKey = 'access' | 'rectification' | 'erasure' | 'portability';
type PrincipleKey = 'minimization' | 'retention' | 'integrity' | 'transparency';
type MeasureKey = 'technical' | 'organizational';
const RIGHTS: { key: RightKey; icon: LucideIcon }[] = [
{ key: 'access', icon: Eye },
{ key: 'rectification', icon: Edit },
{ key: 'erasure', icon: Trash2 },
{ key: 'portability', icon: Download },
];
const PRINCIPLES: { key: PrincipleKey; icon: LucideIcon }[] = [
{ key: 'minimization', icon: Database },
{ key: 'retention', icon: Clock },
{ key: 'integrity', icon: Shield },
{ key: 'transparency', icon: FileText },
];
const MEASURES: MeasureKey[] = ['technical', 'organizational'];
const MEASURE_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
const REGISTER_ITEMS = ['item1', 'item2', 'item3', 'item4', 'item5'] as const;
export default function CompliancePage() { export default function CompliancePage() {
const t = useTranslations('marketing.compliance');
const heroRef = useRef(null); const heroRef = useRef(null);
const contentRef = useRef(null); const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true }); const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true }); const isContentInView = useInView(contentRef, { once: true });
const rights = [
{
icon: Eye,
title: 'Droit d\'accès',
description: 'Obtenez une copie de toutes les données personnelles que nous détenons sur vous.',
},
{
icon: Edit,
title: 'Droit de rectification',
description: 'Faites corriger vos données personnelles si elles sont inexactes ou incomplètes.',
},
{
icon: Trash2,
title: 'Droit à l\'effacement',
description: 'Demandez la suppression de vos données personnelles ("droit à l\'oubli").',
},
{
icon: Download,
title: 'Droit à la portabilité',
description: 'Recevez vos données dans un format structuré, lisible par machine.',
},
];
const principles = [
{
icon: Database,
title: 'Minimisation des données',
description: 'Nous ne collectons que les données strictement nécessaires à nos services.',
},
{
icon: Clock,
title: 'Limitation de conservation',
description: 'Vos données sont conservées uniquement le temps nécessaire.',
},
{
icon: Shield,
title: 'Intégrité et confidentialité',
description: 'Vos données sont protégées contre tout accès non autorisé.',
},
{
icon: FileText,
title: 'Transparence',
description: 'Nous vous informons clairement sur l\'utilisation de vos données.',
},
];
const measures = [
{
category: 'Mesures techniques',
items: [
'Chiffrement des données au repos et en transit',
'Authentification multi-facteurs',
'Journalisation des accès aux données',
'Sauvegardes chiffrées régulières',
'Pseudonymisation des données sensibles',
],
},
{
category: 'Mesures organisationnelles',
items: [
'Délégué à la Protection des Données (DPO) désigné',
'Formation régulière des employés',
'Politiques de sécurité documentées',
'Processus de gestion des incidents',
'Audits de conformité réguliers',
],
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
visible: { visible: {
@ -97,29 +141,30 @@ export default function CompliancePage() {
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" 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"
> >
<Globe className="w-5 h-5 text-brand-turquoise" /> <Globe className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Conformité européenne</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Conformité
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} RGPD
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Xpeditis s'engage à respecter le Règlement Général sur la Protection des Données (RGPD)
et à garantir vos droits en matière de protection des données personnelles.
</p> </p>
<div className="flex items-center justify-center space-x-4"> <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"> <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" /> <CheckCircle className="w-5 h-5 text-brand-green" />
<span className="text-white text-sm">{t('badges.compliant')}</span> <span className="text-white text-sm">Conforme RGPD</span>
</div> </div>
<div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg"> <div className="flex items-center space-x-2 bg-white/10 px-4 py-2 rounded-lg">
<UserCheck className="w-5 h-5 text-brand-green" /> <UserCheck className="w-5 h-5 text-brand-green" />
<span className="text-white text-sm">{t('badges.dpo')}</span> <span className="text-white text-sm">DPO désigné</span>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@ -146,10 +191,10 @@ export default function CompliancePage() {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('rightsTitle')} Vos droits RGPD
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('rightsSubtitle')} Le RGPD vous confère des droits renforcés sur vos données personnelles
</p> </p>
</motion.div> </motion.div>
@ -159,11 +204,11 @@ export default function CompliancePage() {
animate={isContentInView ? 'visible' : 'hidden'} animate={isContentInView ? 'visible' : 'hidden'}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8" className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
> >
{RIGHTS.map((right) => { {rights.map((right, index) => {
const IconComponent = right.icon; const IconComponent = right.icon;
return ( return (
<motion.div <motion.div
key={right.key} key={index}
variants={itemVariants} variants={itemVariants}
whileHover={{ y: -5 }} whileHover={{ y: -5 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center" className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
@ -171,8 +216,8 @@ export default function CompliancePage() {
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
<IconComponent className="w-8 h-8 text-brand-turquoise" /> <IconComponent className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t(`rights.${right.key}.title`)}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">{right.title}</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p> <p className="text-gray-600">{right.description}</p>
</motion.div> </motion.div>
); );
})} })}
@ -186,20 +231,20 @@ export default function CompliancePage() {
className="mt-12 text-center" className="mt-12 text-center"
> >
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
{t('rightsCta.text')} Pour exercer vos droits, connectez-vous à votre compte ou contactez notre DPO
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link <Link
href="/login" href="/login"
className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium" className="px-6 py-3 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium"
> >
{t('rightsCta.login')} Accéder à mon compte
</Link> </Link>
<a <a
href="mailto:dpo@xpeditis.com" 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" className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium"
> >
{t('rightsCta.dpo')} Contacter le DPO
</a> </a>
</div> </div>
</motion.div> </motion.div>
@ -217,19 +262,19 @@ export default function CompliancePage() {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('principlesTitle')} Nos principes de protection des données
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('principlesSubtitle')} Des principes fondamentaux qui guident notre traitement des données
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{PRINCIPLES.map((principle, index) => { {principles.map((principle, index) => {
const IconComponent = principle.icon; const IconComponent = principle.icon;
return ( return (
<motion.div <motion.div
key={principle.key} key={index}
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }} whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }} viewport={{ once: true }}
@ -239,8 +284,8 @@ export default function CompliancePage() {
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4"> <div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-6 h-6 text-brand-green" /> <IconComponent className="w-6 h-6 text-brand-green" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-2">{t(`principles.${principle.key}.title`)}</h3> <h3 className="text-lg font-bold text-brand-navy mb-2">{principle.title}</h3>
<p className="text-gray-600 text-sm">{t(`principles.${principle.key}.description`)}</p> <p className="text-gray-600 text-sm">{principle.description}</p>
</motion.div> </motion.div>
); );
})} })}
@ -259,29 +304,29 @@ export default function CompliancePage() {
className="text-center mb-16" className="text-center mb-16"
> >
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4"> <h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresTitle')} Mesures de protection
</h2> </h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto"> <p className="text-xl text-gray-600 max-w-2xl mx-auto">
{t('measuresSubtitle')} Des mesures techniques et organisationnelles pour assurer la sécurité de vos données
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{MEASURES.map((key, index) => ( {measures.map((measure, index) => (
<motion.div <motion.div
key={key} key={index}
initial={{ opacity: 0, x: index === 0 ? -30 : 30 }} initial={{ opacity: 0, x: index === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl" className="bg-gradient-to-br from-brand-navy to-brand-navy/95 p-8 rounded-2xl"
> >
<h3 className="text-xl font-bold text-white mb-6">{t(`measures.${key}.title`)}</h3> <h3 className="text-xl font-bold text-white mb-6">{measure.category}</h3>
<ul className="space-y-4"> <ul className="space-y-4">
{MEASURE_ITEMS.map((itemKey) => ( {measure.items.map((item, i) => (
<li key={itemKey} className="flex items-center space-x-3 text-white/80"> <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" /> <CheckCircle className="w-5 h-5 text-brand-turquoise flex-shrink-0" />
<span>{t(`measures.${key}.${itemKey}` as any)}</span> <span>{item}</span>
</li> </li>
))} ))}
</ul> </ul>
@ -307,18 +352,33 @@ export default function CompliancePage() {
</div> </div>
<div> <div>
<h3 className="text-2xl font-bold text-brand-navy mb-4"> <h3 className="text-2xl font-bold text-brand-navy mb-4">
{t('register.title')} Registre des traitements
</h3> </h3>
<p className="text-gray-600 mb-6"> <p className="text-gray-600 mb-6">
{t('register.body')} Conformément à l'article 30 du RGPD, nous tenons un registre des activités de traitement
des données personnelles. Ce registre documente :
</p> </p>
<ul className="space-y-3 text-gray-600"> <ul className="space-y-3 text-gray-600">
{REGISTER_ITEMS.map((itemKey) => ( <li className="flex items-center space-x-3">
<li key={itemKey} className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" /> <CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
<span>{t(`register.${itemKey}` as any)}</span> <span>Les finalités de chaque traitement</span>
</li>
<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> </li>
))}
</ul> </ul>
</div> </div>
</div> </div>
@ -338,10 +398,11 @@ export default function CompliancePage() {
> >
<UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" /> <UserCheck className="w-12 h-12 text-brand-turquoise mx-auto mb-4" />
<h3 className="text-2xl font-bold text-white mb-4"> <h3 className="text-2xl font-bold text-white mb-4">
{t('dpo.title')} Contacter notre DPO
</h3> </h3>
<p className="text-white/80 mb-6 max-w-2xl mx-auto"> <p className="text-white/80 mb-6 max-w-2xl mx-auto">
{t('dpo.body')} 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> </p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4"> <div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<a <a
@ -355,7 +416,7 @@ export default function CompliancePage() {
href="/privacy" href="/privacy"
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium" className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
> >
{t('dpo.privacyLink')} Politique de confidentialité
</Link> </Link>
</div> </div>
</motion.div> </motion.div>

View File

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { motion, useInView } from 'framer-motion'; import { motion, useInView } from 'framer-motion';
import { import {
Mail, Mail,
@ -18,26 +17,11 @@ import {
Zap, Zap,
BookOpen, BookOpen,
ArrowRight, ArrowRight,
type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { LandingHeader, LandingFooter } from '@/components/layout'; import { LandingHeader, LandingFooter } from '@/components/layout';
import { sendContactForm } from '@/lib/api/auth'; import { sendContactForm } from '@/lib/api/auth';
type MethodKey = 'email' | 'phone' | 'chat' | 'support';
type SubjectKey = 'demo' | 'pricing' | 'partnership' | 'support' | 'press' | 'careers' | 'other';
const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
{ key: 'email', icon: Mail, color: 'from-blue-500 to-cyan-500' },
{ key: 'phone', icon: Phone, color: 'from-green-500 to-emerald-500' },
{ key: 'chat', icon: MessageSquare, color: 'from-purple-500 to-pink-500' },
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
];
const SUBJECTS: SubjectKey[] = ['demo', 'pricing', 'partnership', 'support', 'press', 'careers', 'other'];
export default function ContactPage() { export default function ContactPage() {
const t = useTranslations('marketing.contact');
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: '', firstName: '',
lastName: '', lastName: '',
@ -80,7 +64,7 @@ export default function ContactPage() {
}); });
setIsSubmitted(true); setIsSubmitted(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('form.genericError')); setError(err.message || "Une erreur est survenue lors de l'envoi. Veuillez réessayer.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -95,6 +79,59 @@ export default function ContactPage() {
})); }));
}; };
const contactMethods = [
{
icon: Mail,
title: 'Email',
description: 'Envoyez-nous un email',
value: 'contact@xpeditis.com',
color: 'from-blue-500 to-cyan-500',
},
{
icon: Phone,
title: 'Téléphone',
description: 'Appelez-nous',
value: '+33 1 23 45 67 89',
color: 'from-green-500 to-emerald-500',
},
{
icon: MessageSquare,
title: 'Chat en direct',
description: 'Discutez avec notre équipe',
value: 'Disponible 24/7',
color: 'from-purple-500 to-pink-500',
},
{
icon: Headphones,
title: 'Support',
description: 'Support client',
value: 'support@xpeditis.com',
color: 'from-orange-500 to-red-500',
},
];
const offices = [
{
city: 'Paris',
address: '123 Avenue des Champs-Élysées',
postalCode: '75008 Paris, France',
phone: '+33 1 23 45 67 89',
email: 'paris@xpeditis.com',
isHQ: true,
},
];
const subjects = [
{ value: '', label: 'Sélectionnez un sujet' },
{ value: 'demo', label: 'Demande de démonstration' },
{ value: 'pricing', label: 'Questions sur les tarifs' },
{ value: 'partnership', label: 'Partenariat' },
{ value: 'support', label: 'Support technique' },
{ value: 'press', label: 'Relations presse' },
{ value: 'careers', label: 'Recrutement' },
{ value: 'other', label: 'Autre' },
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
visible: { visible: {
@ -141,19 +178,20 @@ export default function ContactPage() {
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" 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"
> >
<Mail className="w-5 h-5 text-brand-turquoise" /> <Mail className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Nous contacter</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Une question ?
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} Nous sommes pour vous
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-10 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Notre équipe est disponible pour répondre à toutes vos questions sur notre plateforme,
nos services et nos tarifs. N'hésitez pas à nous contacter !
</p> </p>
</motion.div> </motion.div>
</div> </div>
@ -178,11 +216,11 @@ export default function ContactPage() {
className="max-w-7xl mx-auto px-6 lg:px-8" className="max-w-7xl mx-auto px-6 lg:px-8"
> >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{METHODS.map((method) => { {contactMethods.map((method, index) => {
const IconComponent = method.icon; const IconComponent = method.icon;
return ( return (
<motion.div <motion.div
key={method.key} key={index}
variants={itemVariants} variants={itemVariants}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100" className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
> >
@ -191,9 +229,9 @@ export default function ContactPage() {
> >
<IconComponent className="w-6 h-6 text-white" /> <IconComponent className="w-6 h-6 text-white" />
</div> </div>
<h3 className="text-lg font-bold text-brand-navy mb-1">{t(`methods.${method.key}.title`)}</h3> <h3 className="text-lg font-bold text-brand-navy mb-1">{method.title}</h3>
<p className="text-gray-500 text-sm mb-2">{t(`methods.${method.key}.description`)}</p> <p className="text-gray-500 text-sm mb-2">{method.description}</p>
<p className="text-brand-turquoise font-medium">{t(`methods.${method.key}.value`)}</p> <p className="text-brand-turquoise font-medium">{method.value}</p>
</motion.div> </motion.div>
); );
})} })}
@ -211,9 +249,9 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}} animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">Envoyez-nous un message</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
{t('form.description')} Remplissez le formulaire ci-dessous et nous vous répondrons dans les plus brefs délais.
</p> </p>
{isSubmitted ? ( {isSubmitted ? (
@ -225,9 +263,9 @@ export default function ContactPage() {
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle2 className="w-8 h-8 text-green-600" /> <CheckCircle2 className="w-8 h-8 text-green-600" />
</div> </div>
<h3 className="text-2xl font-bold text-green-800 mb-2">{t('form.successTitle')}</h3> <h3 className="text-2xl font-bold text-green-800 mb-2">Message envoyé !</h3>
<p className="text-green-700 mb-6"> <p className="text-green-700 mb-6">
{t('form.successBody')} Merci pour votre message. Notre équipe vous répondra dans les 24 heures.
</p> </p>
<button <button
onClick={() => { onClick={() => {
@ -244,7 +282,7 @@ export default function ContactPage() {
}} }}
className="text-brand-turquoise font-medium hover:underline" className="text-brand-turquoise font-medium hover:underline"
> >
{t('form.sendAnother')} Envoyer un autre message
</button> </button>
</motion.div> </motion.div>
) : ( ) : (
@ -252,7 +290,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.firstName')} * Prénom *
</label> </label>
<input <input
type="text" type="text"
@ -262,12 +300,12 @@ export default function ContactPage() {
value={formData.firstName} value={formData.firstName}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.firstNamePlaceholder')} placeholder="Jean"
/> />
</div> </div>
<div> <div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.lastName')} * Nom *
</label> </label>
<input <input
type="text" type="text"
@ -277,7 +315,7 @@ export default function ContactPage() {
value={formData.lastName} value={formData.lastName}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.lastNamePlaceholder')} placeholder="Dupont"
/> />
</div> </div>
</div> </div>
@ -285,7 +323,7 @@ export default function ContactPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.email')} * Email *
</label> </label>
<input <input
type="email" type="email"
@ -295,12 +333,12 @@ export default function ContactPage() {
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.emailPlaceholder')} placeholder="jean.dupont@exemple.com"
/> />
</div> </div>
<div> <div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.phone')} Téléphone
</label> </label>
<input <input
type="tel" type="tel"
@ -309,14 +347,14 @@ export default function ContactPage() {
value={formData.phone} value={formData.phone}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.phonePlaceholder')} placeholder="+33 6 12 34 56 78"
/> />
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.company')} Entreprise
</label> </label>
<input <input
type="text" type="text"
@ -325,13 +363,13 @@ export default function ContactPage() {
value={formData.company} value={formData.company}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.companyPlaceholder')} placeholder="Votre entreprise"
/> />
</div> </div>
<div> <div>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.subject')} * Sujet *
</label> </label>
<select <select
id="subject" id="subject"
@ -341,10 +379,9 @@ export default function ContactPage() {
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
> >
<option value="">{t('subjects.placeholder')}</option> {subjects.map((subject) => (
{SUBJECTS.map((key) => ( <option key={subject.value} value={subject.value}>
<option key={key} value={key}> {subject.label}
{t(`subjects.${key}`)}
</option> </option>
))} ))}
</select> </select>
@ -352,7 +389,7 @@ export default function ContactPage() {
<div> <div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
{t('form.message')} * Message *
</label> </label>
<textarea <textarea
id="message" id="message"
@ -362,7 +399,7 @@ export default function ContactPage() {
value={formData.message} value={formData.message}
onChange={handleChange} onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none" className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none"
placeholder={t('form.messagePlaceholder')} placeholder="Comment pouvons-nous vous aider ?"
/> />
</div> </div>
@ -380,12 +417,12 @@ export default function ContactPage() {
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
<span>{t('form.submitting')}</span> <span>Envoi en cours...</span>
</> </>
) : ( ) : (
<> <>
<Send className="w-5 h-5" /> <Send className="w-5 h-5" />
<span>{t('form.submit')}</span> <span>Envoyer le message</span>
</> </>
)} )}
</button> </button>
@ -399,72 +436,87 @@ export default function ContactPage() {
animate={isFormInView ? { opacity: 1, x: 0 } : {}} animate={isFormInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
> >
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-6">Notre bureau</h2>
<p className="text-gray-600 mb-8"> <p className="text-gray-600 mb-8">
{t('office.subtitle')} Retrouvez-nous à Paris ou contactez-nous par email.
</p> </p>
<div className="space-y-6"> <div className="space-y-6">
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg"> {offices.map((office, index) => (
<div
key={index}
className={`bg-white p-6 rounded-2xl border-2 transition-all ${
office.isHQ
? 'border-brand-turquoise shadow-lg'
: 'border-gray-200 hover:border-brand-turquoise/50'
}`}
>
<div className="flex items-start space-x-4"> <div className="flex items-start space-x-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 bg-brand-turquoise"> <div
<Building2 className="w-6 h-6 text-white" /> className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${
office.isHQ ? 'bg-brand-turquoise' : 'bg-gray-100'
}`}
>
<Building2 className={`w-6 h-6 ${office.isHQ ? 'text-white' : 'text-gray-600'}`} />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
<h3 className="text-xl font-bold text-brand-navy">{t('office.city')}</h3> <h3 className="text-xl font-bold text-brand-navy">{office.city}</h3>
{office.isHQ && (
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full"> <span className="px-2 py-1 bg-brand-turquoise/10 text-brand-turquoise text-xs font-medium rounded-full">
{t('office.hqBadge')} Siège social
</span> </span>
)}
</div> </div>
<div className="space-y-2 text-gray-600"> <div className="space-y-2 text-gray-600">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<MapPin className="w-4 h-4 text-gray-400" /> <MapPin className="w-4 h-4 text-gray-400" />
<span>{t('office.address')}</span> <span>{office.address}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-gray-400 ml-6">{t('office.postalCode')}</span> <span className="text-gray-400 ml-6">{office.postalCode}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" /> <Phone className="w-4 h-4 text-gray-400" />
<a href={`tel:${t('office.phone').replace(/\s/g, '')}`} className="hover:text-brand-turquoise"> <a href={`tel:${office.phone.replace(/\s/g, '')}`} className="hover:text-brand-turquoise">
{t('office.phone')} {office.phone}
</a> </a>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" /> <Mail className="w-4 h-4 text-gray-400" />
<a href={`mailto:${t('office.email')}`} className="hover:text-brand-turquoise"> <a href={`mailto:${office.email}`} className="hover:text-brand-turquoise">
{t('office.email')} {office.email}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
))}
</div> </div>
{/* Hours */} {/* Hours */}
<div className="mt-8 bg-gray-50 p-6 rounded-2xl"> <div className="mt-8 bg-gray-50 p-6 rounded-2xl">
<div className="flex items-center space-x-3 mb-4"> <div className="flex items-center space-x-3 mb-4">
<Clock className="w-6 h-6 text-brand-turquoise" /> <Clock className="w-6 h-6 text-brand-turquoise" />
<h3 className="text-lg font-bold text-brand-navy">{t('hours.title')}</h3> <h3 className="text-lg font-bold text-brand-navy">Horaires d'ouverture</h3>
</div> </div>
<div className="space-y-2 text-gray-600"> <div className="space-y-2 text-gray-600">
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t('hours.weekdays')}</span> <span>Lundi - Vendredi</span>
<span className="font-medium">{t('hours.weekdaysHours')}</span> <span className="font-medium">9h00 - 18h00</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t('hours.saturday')}</span> <span>Samedi</span>
<span className="font-medium">{t('hours.saturdayHours')}</span> <span className="font-medium">10h00 - 14h00</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>{t('hours.sunday')}</span> <span>Dimanche</span>
<span className="font-medium text-gray-400">{t('hours.closed')}</span> <span className="font-medium text-gray-400">Fermé</span>
</div> </div>
</div> </div>
<p className="mt-4 text-sm text-gray-500"> <p className="mt-4 text-sm text-gray-500">
{t('hours.supportNote')} * Support technique disponible 24/7 pour les clients Enterprise
</p> </p>
</div> </div>
</motion.div> </motion.div>
@ -472,7 +524,7 @@ export default function ContactPage() {
</div> </div>
</section> </section>
{/* Section 1 : After submit */} {/* Section 1 : Ce qui se passe après l'envoi */}
<section ref={afterSubmitRef} className="py-16 bg-white"> <section ref={afterSubmitRef} className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
@ -481,6 +533,7 @@ export default function ContactPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden p-8 lg:p-12" className="relative bg-gradient-to-br from-brand-navy to-brand-navy/90 rounded-3xl overflow-hidden p-8 lg:p-12"
> >
{/* Decorative blobs */}
<div className="absolute inset-0 opacity-10 pointer-events-none"> <div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute -top-10 -left-10 w-64 h-64 bg-brand-turquoise rounded-full blur-3xl" /> <div className="absolute -top-10 -left-10 w-64 h-64 bg-brand-turquoise rounded-full blur-3xl" />
<div className="absolute -bottom-10 -right-10 w-64 h-64 bg-brand-green rounded-full blur-3xl" /> <div className="absolute -bottom-10 -right-10 w-64 h-64 bg-brand-green rounded-full blur-3xl" />
@ -492,15 +545,15 @@ export default function ContactPage() {
<Mail className="w-5 h-5 text-brand-turquoise" /> <Mail className="w-5 h-5 text-brand-turquoise" />
</div> </div>
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs"> <span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
{t('afterSubmit.badge')} Après votre envoi
</span> </span>
</div> </div>
<h2 className="text-2xl lg:text-3xl font-bold text-white mb-8"> <h2 className="text-2xl lg:text-3xl font-bold text-white mb-8">
{t('afterSubmit.title')} Que se passe-t-il après l'envoi de votre message ?
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Commitment */} {/* Notre engagement */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}} animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -511,17 +564,19 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-brand-turquoise/30 rounded-xl flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-5 h-5 text-brand-turquoise" /> <CheckCircle2 className="w-5 h-5 text-brand-turquoise" />
</div> </div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.commitmentTitle')}</h3> <h3 className="text-lg font-bold text-white">Notre engagement</h3>
</div> </div>
<p className="text-white/80 leading-relaxed"> <p className="text-white/80 leading-relaxed">
{t('afterSubmit.commitmentBody1')} Dès réception de votre demande, un de nos experts logistiques analyse votre
profil et vos besoins. Vous recevrez une réponse personnalisée ou une invitation
pour une démonstration de la plateforme{' '}
<span className="text-brand-turquoise font-semibold"> <span className="text-brand-turquoise font-semibold">
{t('afterSubmit.commitmentHighlight')} sous 48 heures ouvrées.
</span> </span>
</p> </p>
</motion.div> </motion.div>
{/* Security */} {/* Sécurité */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}} animate={isAfterSubmitInView ? { opacity: 1, y: 0 } : {}}
@ -532,14 +587,14 @@ export default function ContactPage() {
<div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 bg-brand-green/30 rounded-xl flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-brand-green" /> <Shield className="w-5 h-5 text-brand-green" />
</div> </div>
<h3 className="text-lg font-bold text-white">{t('afterSubmit.securityTitle')}</h3> <h3 className="text-lg font-bold text-white">Sécurité</h3>
</div> </div>
<p className="text-white/80 leading-relaxed"> <p className="text-white/80 leading-relaxed">
{t('afterSubmit.securityBody1')} Vos informations sont protégées et traitées conformément à notre{' '}
<Link href="/privacy" className="text-brand-turquoise font-semibold hover:underline"> <a href="/privacy" className="text-brand-turquoise font-semibold hover:underline">
{t('afterSubmit.privacyLink')} politique de confidentialité
</Link> </a>
{t('afterSubmit.securityBody2')} . Aucune donnée n'est partagée avec des tiers sans votre accord.
</p> </p>
</motion.div> </motion.div>
</div> </div>
@ -548,7 +603,7 @@ export default function ContactPage() {
</div> </div>
</section> </section>
{/* Section 2: Quick access */} {/* Section 2 : Accès Rapide */}
<section ref={quickAccessRef} className="py-16 bg-gray-50"> <section ref={quickAccessRef} className="py-16 bg-gray-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div <motion.div
@ -558,15 +613,15 @@ export default function ContactPage() {
> >
<div className="text-center mb-10"> <div className="text-center mb-10">
<span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs"> <span className="text-brand-turquoise font-semibold uppercase tracking-widest text-xs">
{t('quickAccess.badge')} Accès rapide
</span> </span>
<h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2"> <h2 className="text-2xl lg:text-3xl font-bold text-brand-navy mt-2">
{t('quickAccess.title')} Besoin d'une réponse immédiate ?
</h2> </h2>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{/* Instant pricing */} {/* Tarification instantanée */}
<motion.div <motion.div
initial={{ opacity: 0, x: -30 }} initial={{ opacity: 0, x: -30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}} animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -577,22 +632,22 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0"> <div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<Zap className="w-7 h-7 text-white" /> <Zap className="w-7 h-7 text-white" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.pricingTitle')}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">Tarification instantanée</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6"> <p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.pricingBody1')} N'attendez pas notre retour pour vos prix. Utilisez notre moteur{' '}
<span className="font-semibold text-brand-navy">{t('quickAccess.pricingHighlight')}</span> <span className="font-semibold text-brand-navy">Click&amp;Ship</span> pour obtenir
{t('quickAccess.pricingBody2')} une cotation de fret maritime en moins de 60 secondes.
</p> </p>
<Link <a
href="/dashboard" href="/dashboard"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-xl font-semibold hover:bg-brand-turquoise/90 transition-all group" className="inline-flex items-center justify-center space-x-2 px-6 py-3 bg-brand-turquoise text-white rounded-xl font-semibold hover:bg-brand-turquoise/90 transition-all group"
> >
<span>{t('quickAccess.pricingCta')}</span> <span>Accéder au Dashboard</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link> </a>
</motion.div> </motion.div>
{/* Wiki */} {/* Wiki Maritime */}
<motion.div <motion.div
initial={{ opacity: 0, x: 30 }} initial={{ opacity: 0, x: 30 }}
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}} animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
@ -603,19 +658,19 @@ export default function ContactPage() {
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0"> <div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<BookOpen className="w-7 h-7 text-brand-turquoise" /> <BookOpen className="w-7 h-7 text-brand-turquoise" />
</div> </div>
<h3 className="text-xl font-bold text-brand-navy mb-3">{t('quickAccess.wikiTitle')}</h3> <h3 className="text-xl font-bold text-brand-navy mb-3">Aide rapide</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6"> <p className="text-gray-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.wikiBody1')} Une question sur les Incoterms ou la documentation export ? Notre{' '}
<span className="font-semibold text-brand-navy">{t('quickAccess.wikiHighlight')}</span> <span className="font-semibold text-brand-navy">Wiki Maritime</span> contient déjà
{t('quickAccess.wikiBody2')} les réponses aux questions les plus fréquentes.
</p> </p>
<Link <a
href="/dashboard/wiki" href="/dashboard/wiki"
className="inline-flex items-center justify-center space-x-2 px-6 py-3 border-2 border-brand-navy text-brand-navy rounded-xl font-semibold hover:bg-brand-navy hover:text-white transition-all group" className="inline-flex items-center justify-center space-x-2 px-6 py-3 border-2 border-brand-navy text-brand-navy rounded-xl font-semibold hover:bg-brand-navy hover:text-white transition-all group"
> >
<span>{t('quickAccess.wikiCta')}</span> <span>Consulter le Wiki</span>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link> </a>
</motion.div> </motion.div>
</div> </div>
</motion.div> </motion.div>

View File

@ -2,77 +2,63 @@
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';
type CookieTypeKey = 'essential' | 'analytics' | 'marketing' | 'functional';
interface CookieRow {
name: string;
purposeKey: string;
durationKey: string;
}
interface CookieTypeConfig {
key: CookieTypeKey;
icon: LucideIcon;
required: boolean;
cookies: CookieRow[];
}
const COOKIE_TYPES: CookieTypeConfig[] = [
{
key: 'essential',
icon: Shield,
required: true,
cookies: [
{ name: 'session_id', purposeKey: 'session_id', durationKey: 'session' },
{ name: 'csrf_token', purposeKey: 'csrf_token', durationKey: 'session' },
{ name: 'cookie_consent', purposeKey: 'cookie_consent', durationKey: 'year1' },
],
},
{
key: 'analytics',
icon: BarChart3,
required: false,
cookies: [
{ name: '_ga', purposeKey: '_ga', durationKey: 'years2' },
{ name: '_gid', purposeKey: '_gid', durationKey: 'hours24' },
{ name: '_gat', purposeKey: '_gat', durationKey: 'minute1' },
],
},
{
key: 'marketing',
icon: Target,
required: false,
cookies: [
{ name: '_fbp', purposeKey: '_fbp', durationKey: 'months3' },
{ name: 'li_fat_id', purposeKey: 'li_fat_id', durationKey: 'days30' },
{ name: 'hubspotutk', purposeKey: 'hubspotutk', durationKey: 'months13' },
],
},
{
key: 'functional',
icon: Settings,
required: false,
cookies: [
{ name: 'language', purposeKey: 'language', durationKey: 'year1' },
{ name: 'theme', purposeKey: 'theme', durationKey: 'year1' },
{ name: 'recent_searches', purposeKey: 'recent_searches', durationKey: 'days30' },
],
},
];
export default function CookiesPage() { export default function CookiesPage() {
const t = useTranslations('marketing.cookies');
const tCommon = useTranslations('marketing.common');
const heroRef = useRef(null); const heroRef = useRef(null);
const contentRef = useRef(null); const contentRef = useRef(null);
const isHeroInView = useInView(heroRef, { once: true }); const isHeroInView = useInView(heroRef, { once: true });
const isContentInView = useInView(contentRef, { once: true }); const isContentInView = useInView(contentRef, { once: true });
const cookieTypes = [
{
icon: Shield,
title: 'Cookies essentiels',
description: 'Nécessaires au fonctionnement du site',
required: true,
cookies: [
{ name: 'session_id', purpose: 'Maintien de votre session de connexion', duration: 'Session' },
{ name: 'csrf_token', purpose: 'Protection contre les attaques CSRF', duration: 'Session' },
{ name: 'cookie_consent', purpose: 'Mémorisation de vos préférences cookies', duration: '1 an' },
],
},
{
icon: BarChart3,
title: 'Cookies analytiques',
description: 'Nous aident à améliorer notre plateforme',
required: false,
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' },
],
},
{
icon: Target,
title: 'Cookies marketing',
description: 'Permettent de personnaliser les publicités',
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' },
{ name: 'hubspotutk', purpose: 'HubSpot - Identification des visiteurs', duration: '13 mois' },
],
},
{
icon: Settings,
title: 'Cookies fonctionnels',
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' },
{ name: 'theme', purpose: 'Mémorisation du thème (clair/sombre)', duration: '1 an' },
{ name: 'recent_searches', purpose: 'Historique de vos recherches récentes', duration: '30 jours' },
],
},
];
const containerVariants = { const containerVariants = {
hidden: { opacity: 0, y: 50 }, hidden: { opacity: 0, y: 50 },
visible: { visible: {
@ -119,25 +105,29 @@ export default function CookiesPage() {
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" 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"
> >
<Cookie className="w-5 h-5 text-brand-turquoise" /> <Cookie className="w-5 h-5 text-brand-turquoise" />
<span className="text-white/90 text-sm font-medium">{t('badge')}</span> <span className="text-white/90 text-sm font-medium">Transparence</span>
</motion.div> </motion.div>
<h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight"> <h1 className="text-4xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{t('title1')} Politique de
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
{t('title2')} Cookies
</span> </span>
</h1> </h1>
<p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed"> <p className="text-xl text-white/80 mb-6 max-w-3xl mx-auto leading-relaxed">
{t('intro')} Découvrez comment nous utilisons les cookies pour améliorer votre expérience
sur Xpeditis et comment vous pouvez gérer vos préférences.
</p> </p>
<p className="text-white/60 text-sm">{tCommon('lastUpdated')}</p> <p className="text-white/60 text-sm">
Dernière mise à jour : Janvier 2025
</p>
</motion.div> </motion.div>
</div> </div>
{/* Wave */}
<div className="absolute bottom-0 left-0 right-0"> <div className="absolute bottom-0 left-0 right-0">
<svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none"> <svg className="w-full h-16" viewBox="0 0 1440 60" preserveAspectRatio="none">
<path <path
@ -158,9 +148,16 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
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"
> >
<h2 className="text-2xl font-bold text-brand-navy mb-4">{t('introBoxTitle')}</h2> <h2 className="text-2xl font-bold text-brand-navy mb-4">Qu'est-ce qu'un cookie ?</h2>
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p> <p className="text-gray-600 leading-relaxed mb-4">
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p> Un cookie est un petit fichier texte stocké sur votre appareil (ordinateur, tablette, smartphone)
lorsque vous visitez un site web. Les cookies permettent au site de mémoriser vos actions et
préférences sur une période donnée.
</p>
<p className="text-gray-600 leading-relaxed">
Les cookies ne contiennent pas d'informations personnellement identifiables et ne peuvent pas
accéder aux données stockées sur votre appareil.
</p>
</motion.div> </motion.div>
</div> </div>
</section> </section>
@ -174,8 +171,10 @@ export default function CookiesPage() {
transition={{ duration: 0.8 }} transition={{ duration: 0.8 }}
className="text-center mb-12" className="text-center mb-12"
> >
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2> <h2 className="text-3xl font-bold text-brand-navy mb-4">Types de cookies utilisés</h2>
<p className="text-gray-600">{t('typesSubtitle')}</p> <p className="text-gray-600">
Nous utilisons différents types de cookies sur notre plateforme
</p>
</motion.div> </motion.div>
<motion.div <motion.div
@ -184,11 +183,11 @@ export default function CookiesPage() {
animate={isContentInView ? 'visible' : 'hidden'} animate={isContentInView ? 'visible' : 'hidden'}
className="space-y-8" className="space-y-8"
> >
{COOKIE_TYPES.map((type) => { {cookieTypes.map((type, index) => {
const IconComponent = type.icon; const IconComponent = type.icon;
return ( return (
<motion.div <motion.div
key={type.key} key={index}
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"
> >
@ -198,22 +197,18 @@ export default function CookiesPage() {
<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"> <h3 className="text-xl font-bold text-brand-navy">{type.title}</h3>
{t(`types.${type.key}.title`)} <p className="text-gray-500 text-sm">{type.description}</p>
</h3>
<p className="text-gray-500 text-sm">
{t(`types.${type.key}.description`)}
</p>
</div> </div>
</div> </div>
{type.required ? ( {type.required ? (
<span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full"> <span className="px-3 py-1 bg-brand-navy/10 text-brand-navy text-xs font-medium rounded-full">
{t('required')} Requis
</span> </span>
) : ( ) : (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<ToggleLeft className="w-8 h-8 text-gray-400" /> <ToggleLeft className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-500">{t('optional')}</span> <span className="text-sm text-gray-500">Optionnel</span>
</div> </div>
)} )}
</div> </div>
@ -222,27 +217,17 @@ export default function CookiesPage() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 font-semibold text-brand-navy"> <th className="text-left py-3 px-4 font-semibold text-brand-navy">Nom</th>
{t('tableHeaders.name')} <th className="text-left py-3 px-4 font-semibold text-brand-navy">Finalité</th>
</th> <th className="text-left py-3 px-4 font-semibold text-brand-navy">Durée</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.purpose')}
</th>
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.duration')}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{type.cookies.map((cookie) => ( {type.cookies.map((cookie, i) => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0"> <tr key={i} className="border-b border-gray-100 last:border-0">
<td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td> <td className="py-3 px-4 font-mono text-brand-turquoise">{cookie.name}</td>
<td className="py-3 px-4 text-gray-600"> <td className="py-3 px-4 text-gray-600">{cookie.purpose}</td>
{t(`purposes.${cookie.purposeKey}` as any)} <td className="py-3 px-4 text-gray-500">{cookie.duration}</td>
</td>
<td className="py-3 px-4 text-gray-500">
{t(`durations.${cookie.durationKey}` as any)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -260,15 +245,19 @@ export default function CookiesPage() {
transition={{ duration: 0.8, delay: 0.4 }} transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200" className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200"
> >
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('manageTitle')}</h3> <h3 className="text-2xl font-bold text-brand-navy mb-4">Comment gérer vos cookies ?</h3>
<div className="space-y-4 text-gray-600"> <div className="space-y-4 text-gray-600">
<p>{t('manageIntro')}</p> <p>
Vous pouvez à tout moment modifier vos préférences en matière de cookies :
</p>
<ul className="list-disc pl-6 space-y-2"> <ul className="list-disc pl-6 space-y-2">
<li>{t('manageBullet1')}</li> <li>Via notre bandeau de consentement accessible en bas de chaque page</li>
<li>{t('manageBullet2')}</li> <li>Dans les paramètres de votre navigateur (Chrome, Firefox, Safari, Edge)</li>
<li>{t('manageBullet3')}</li> <li>En utilisant des outils tiers de gestion des cookies</li>
</ul> </ul>
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p> <p className="text-sm text-gray-500 mt-4">
Note : La désactivation de certains cookies peut affecter votre expérience sur notre plateforme.
</p>
</div> </div>
</motion.div> </motion.div>
@ -280,8 +269,11 @@ export default function CookiesPage() {
className="mt-16 bg-gradient-to-br from-brand-navy to-brand-navy/95 p-10 rounded-3xl text-center" 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" /> <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> <h3 className="text-2xl font-bold text-white mb-4">Des questions sur les cookies ?</h3>
<p className="text-white/80 mb-6">{t('contact.body')}</p> <p className="text-white/80 mb-6">
Notre équipe est disponible pour répondre à toutes vos questions
concernant l'utilisation des cookies sur notre plateforme.
</p>
<a <a
href="mailto:privacy@xpeditis.com" href="mailto:privacy@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" 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"

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,11 +1,9 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin'; import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react'; import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document { interface Document {
id: string; id: string;
@ -47,10 +45,6 @@ interface DocumentWithBooking extends Document {
} }
export default function AdminDocumentsPage() { export default function AdminDocumentsPage() {
const t = useTranslations('dashboard.admin.documents');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [bookings, setBookings] = useState<Booking[]>([]); const [bookings, setBookings] = useState<Booking[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]); const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -72,6 +66,19 @@ export default function AdminDocumentsPage() {
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`; return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
}; };
// Extract filename from MinIO URL
const extractFileName = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const parts = pathname.split('/');
const fileName = parts[parts.length - 1];
return decodeURIComponent(fileName);
} catch {
return 'document';
}
};
// Get file extension and type // Get file extension and type
const getFileType = (fileName: string): string => { const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || ''; const ext = fileName.split('.').pop()?.toLowerCase() || '';
@ -98,6 +105,7 @@ export default function AdminDocumentsPage() {
const allBookings = response.bookings || []; const allBookings = response.bookings || [];
setBookings(allBookings); setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = []; const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>(); const userIds = new Set<string>();
@ -105,15 +113,28 @@ export default function AdminDocumentsPage() {
userIds.add(booking.userId); userIds.add(booking.userId);
if (booking.documents && booking.documents.length > 0) { if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: Document) => { booking.documents.forEach((doc: Document) => {
// Debug: Log document structure
console.log('Document structure:', doc);
// Use the correct field names from the backend
const actualFileName = doc.fileName || doc.name || 'document'; const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || ''; const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || ''; const actualMimeType = doc.mimeType || doc.type || '';
console.log('Extracted:', {
fileName: actualFileName,
filePath: actualFilePath,
mimeType: actualMimeType,
});
// Extract clean file type from mimeType or fileName
let fileType = ''; let fileType = '';
if (actualMimeType.includes('/')) { if (actualMimeType.includes('/')) {
// It's a MIME type like "application/pdf"
const parts = actualMimeType.split('/'); const parts = actualMimeType.split('/');
fileType = getFileType(parts[1]); fileType = getFileType(parts[1]);
} else { } else {
// It's already a type or we extract from filename
fileType = getFileType(actualFileName); fileType = getFileType(actualFileName);
} }
@ -133,8 +154,10 @@ export default function AdminDocumentsPage() {
} }
}); });
// Fetch user names using the API client
try { try {
const usersData = await getAllUsers(); const usersData = await getAllUsers();
console.log('Users data:', usersData);
if (usersData && usersData.users) { if (usersData && usersData.users) {
const usersMap = new Map( const usersMap = new Map(
@ -144,13 +167,18 @@ export default function AdminDocumentsPage() {
}) })
); );
console.log('Users map:', usersMap);
// Enrich documents with user names
allDocuments.forEach(doc => { allDocuments.forEach(doc => {
const userName = usersMap.get(doc.userId); const userName = usersMap.get(doc.userId);
doc.userName = userName || doc.userId.substring(0, 8) + '...'; doc.userName = userName || doc.userId.substring(0, 8) + '...';
console.log(`User ${doc.userId} mapped to: ${doc.userName}`);
}); });
} }
} catch (userError) { } catch (userError) {
console.error('Failed to fetch user names:', userError); console.error('Failed to fetch user names:', userError);
// If user fetch fails, keep the userId as fallback
allDocuments.forEach(doc => { allDocuments.forEach(doc => {
doc.userName = doc.userId.substring(0, 8) + '...'; doc.userName = doc.userId.substring(0, 8) + '...';
}); });
@ -159,22 +187,24 @@ export default function AdminDocumentsPage() {
setDocuments(allDocuments); setDocuments(allDocuments);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('loadError')); setError(err.message || 'Failed to load documents');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [t]); }, []);
useEffect(() => { useEffect(() => {
fetchBookingsAndDocuments(); fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]); }, [fetchBookingsAndDocuments]);
// Get unique users for filter (with names)
const uniqueUsers = Array.from( const uniqueUsers = Array.from(
new Map( new Map(
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }]) documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
).values() ).values()
); );
// Filter documents
const filteredDocuments = documents.filter(doc => { const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' || const matchesSearch = searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) || (doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
@ -190,11 +220,13 @@ export default function AdminDocumentsPage() {
return matchesSearch && matchesUser && matchesQuote; return matchesSearch && matchesUser && matchesQuote;
}); });
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage); const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage; const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage; const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex); const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]); }, [searchTerm, filterUserId, filterQuoteNumber]);
@ -237,13 +269,13 @@ export default function AdminDocumentsPage() {
}; };
const handleDeleteDocument = async (bookingId: string, documentId: string) => { const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm(t('confirmDelete'))) return; if (!window.confirm('Supprimer définitivement ce document ?')) return;
setDeletingId(documentId); setDeletingId(documentId);
try { try {
await deleteAdminDocument(bookingId, documentId); await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId)); setDocuments(prev => prev.filter(d => d.id !== documentId));
} 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);
} }
@ -251,6 +283,7 @@ export default function AdminDocumentsPage() {
const handleDownload = async (url: string, fileName: string) => { const handleDownload = async (url: string, fileName: string) => {
try { try {
// Try direct download first
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = fileName; link.download = fileName;
@ -260,6 +293,7 @@ export default function AdminDocumentsPage() {
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => { setTimeout(async () => {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@ -286,8 +320,7 @@ export default function AdminDocumentsPage() {
}, 100); }, 100);
} catch (error) { } catch (error) {
console.error('Error downloading file:', error); console.error('Error downloading file:', error);
const message = error instanceof Error ? error.message : t('unknownError'); alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
alert(t('downloadError', { message }));
} }
}; };
@ -296,7 +329,7 @@ export default function AdminDocumentsPage() {
<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 documents...</p>
</div> </div>
</div> </div>
); );
@ -304,25 +337,30 @@ export default function AdminDocumentsPage() {
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>
/> <h1 className="text-2xl font-bold text-gray-900">Gestion des Documents</h1>
<p className="mt-1 text-sm text-gray-500">
Liste de tous les documents des devis CSV
</p>
</div>
</div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 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-sm text-gray-500">{t('stats.totalDocs')}</div> <div className="text-sm text-gray-500">Total Documents</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div> <div className="text-2xl font-bold text-gray-900">{documents.length}</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-sm text-gray-500">{t('stats.bookingsWithDocs')}</div> <div className="text-sm text-gray-500">Devis avec Documents</div>
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length} {bookings.filter(b => b.documents && b.documents.length > 0).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-sm text-gray-500">{t('stats.filtered')}</div> <div className="text-sm text-gray-500">Documents Filtrés</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div> <div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div> </div>
</div> </div>
@ -332,11 +370,11 @@ export default function AdminDocumentsPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.search')} Recherche
</label> </label>
<input <input
type="text" type="text"
placeholder={t('filters.searchPlaceholder')} placeholder="Nom, type, route..."
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" 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"
@ -344,11 +382,11 @@ export default function AdminDocumentsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.quoteNumber')} Numéro de Devis
</label> </label>
<input <input
type="text" type="text"
placeholder={t('filters.quoteNumberPlaceholder')} placeholder="Ex: #F2CAD5E1"
value={filterQuoteNumber} value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)} onChange={e => setFilterQuoteNumber(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" 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"
@ -356,14 +394,14 @@ export default function AdminDocumentsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.user')} Utilisateur
</label> </label>
<select <select
value={filterUserId} value={filterUserId}
onChange={e => setFilterUserId(e.target.value)} onChange={e => setFilterUserId(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" 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"
> >
<option value="all">{t('filters.allUsers')}</option> <option value="all">Tous les utilisateurs</option>
{uniqueUsers.map(user => ( {uniqueUsers.map(user => (
<option key={user.id} value={user.id}> <option key={user.id} value={user.id}>
{user.name} {user.name}
@ -387,25 +425,25 @@ export default function AdminDocumentsPage() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.name')} Nom du Document
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.type')} Type
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.quoteNumber')} Numéro de Devis
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')} Route
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')} Statut
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.user')} Utilisateur
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')} Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@ -413,7 +451,7 @@ export default function AdminDocumentsPage() {
{paginatedDocuments.length === 0 ? ( {paginatedDocuments.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500"> <td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{t('table.empty')} Aucun document trouvé
</td> </td>
</tr> </tr>
) : ( ) : (
@ -481,27 +519,27 @@ export default function AdminDocumentsPage() {
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('pagination.previous')} Précédent
</button> </button>
<button <button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))} onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t('pagination.next')} Suivant
</button> </button>
</div> </div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between"> <div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span> {t('pagination.to')}{' '} Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> {t('pagination.on')}{' '} <span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> sur{' '}
<span className="font-medium">{filteredDocuments.length}</span> {t('pagination.results')} <span className="font-medium">{filteredDocuments.length}</span> résultats
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label> <label className="text-sm text-gray-700">Par page:</label>
<select <select
value={itemsPerPage} value={itemsPerPage}
onChange={(e) => { onChange={(e) => {
@ -523,7 +561,7 @@ export default function AdminDocumentsPage() {
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span className="sr-only">{t('pagination.previous')}</span> <span className="sr-only">Précédent</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
@ -562,7 +600,7 @@ export default function AdminDocumentsPage() {
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<span className="sr-only">{t('pagination.next')}</span> <span className="sr-only">Suivant</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg> </svg>
@ -602,7 +640,7 @@ export default function AdminDocumentsPage() {
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg> </svg>
<span className="text-sm font-medium text-gray-700">{t('menu.download')}</span> <span className="text-sm font-medium text-gray-700">Télécharger</span>
</button> </button>
<button <button
onClick={() => { onClick={() => {
@ -618,7 +656,7 @@ export default function AdminDocumentsPage() {
<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>
</> </>
); );

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>
<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"> <div className="flex items-center gap-2">
<button <button
onClick={fetchLogs} onClick={fetchLogs}
disabled={loading} disabled={loading}
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" 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' : ''}`} /> <RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{t('refresh')}</span> Actualiser
</button> </button>
<div className="relative group"> <div className="relative group">
<button <button
disabled={exportLoading || loading} disabled={exportLoading || loading}
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="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"
> >
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
<span className="hidden sm:inline">{exportLoading ? t('exporting') : t('export')}</span> {exportLoading ? 'Export...' : 'Exporter'}
</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"> <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
onClick={() => handleExport('csv')} onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
> >
{t('downloadCsv')} Télécharger CSV
</button> </button>
<button <button
onClick={() => handleExport('json')} onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50" className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
> >
{t('downloadJson')} Télécharger JSON
</button> </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,10 +1,8 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin'; import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations'; import { createOrganization, updateOrganization } from '@/lib/api/organizations';
import { PageHeader } from '@/components/ui/PageHeader';
interface Organization { interface Organization {
id: string; id: string;
@ -31,8 +29,6 @@ interface Organization {
} }
export default function AdminOrganizationsPage() { export default function AdminOrganizationsPage() {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -89,7 +85,7 @@ export default function AdminOrganizationsPage() {
setOrganizations(response.organizations || []); setOrganizations(response.organizations || []);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('loadError')); setError(err.message || 'Failed to load organizations');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -98,9 +94,10 @@ export default function AdminOrganizationsPage() {
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
// Transform formData to match API expected format
const apiData = { const apiData = {
name: formData.name, name: formData.name,
type: formData.type as any, type: formData.type as any, // OrganizationType
address_street: formData.address.street, address_street: formData.address.street,
address_city: formData.address.city, address_city: formData.address.city,
address_postal_code: formData.address.postalCode, address_postal_code: formData.address.postalCode,
@ -114,7 +111,7 @@ export default function AdminOrganizationsPage() {
setShowCreateModal(false); setShowCreateModal(false);
resetForm(); resetForm();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('createError')); alert(err.message || 'Failed to create organization');
} }
}; };
@ -129,7 +126,7 @@ export default function AdminOrganizationsPage() {
setSelectedOrg(null); setSelectedOrg(null);
resetForm(); resetForm();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('updateError')); alert(err.message || 'Failed to update organization');
} }
}; };
@ -159,41 +156,41 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await verifySiret(orgId); const result = await verifySiret(orgId);
if (result.verified) { if (result.verified) {
alert(t('siretVerified', { companyName: result.companyName || 'N/A', address: result.address || 'N/A' })); alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
await fetchOrganizations(); await fetchOrganizations();
} else { } else {
alert(result.message || t('siretInvalid')); alert(result.message || 'SIRET invalide ou introuvable.');
} }
} catch (err: any) { } catch (err: any) {
alert(err.message || t('siretError')); alert(err.message || 'Erreur lors de la verification du SIRET');
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
}; };
const handleApproveSiret = async (orgId: string) => { const handleApproveSiret = async (orgId: string) => {
if (!confirm(t('confirmApprove'))) return; if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
try { try {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await approveSiret(orgId); const result = await approveSiret(orgId);
alert(result.message); alert(result.message);
await fetchOrganizations(); await fetchOrganizations();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('siretApproveError')); alert(err.message || 'Erreur lors de l\'approbation');
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
}; };
const handleRejectSiret = async (orgId: string) => { const handleRejectSiret = async (orgId: string) => {
if (!confirm(t('confirmReject'))) return; if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
try { try {
setVerifyingId(orgId); setVerifyingId(orgId);
const result = await rejectSiret(orgId); const result = await rejectSiret(orgId);
alert(result.message); alert(result.message);
await fetchOrganizations(); await fetchOrganizations();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('siretRejectError')); alert(err.message || 'Erreur lors du refus');
} finally { } finally {
setVerifyingId(null); setVerifyingId(null);
} }
@ -216,20 +213,12 @@ export default function AdminOrganizationsPage() {
setShowEditModal(true); setShowEditModal(true);
}; };
const getTypeLabel = (type: string) => {
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
if (allowed.includes(type)) {
return t(`types.${type}` as any);
}
return type.replace('_', ' ');
};
if (loading) { if (loading) {
return ( return (
<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">Loading organizations...</p>
</div> </div>
</div> </div>
); );
@ -237,18 +226,21 @@ export default function AdminOrganizationsPage() {
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">Organization Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all organizations in the system
</p>
</div>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
{t('create')} + Create Organization
</button> </button>
} </div>
/>
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
@ -269,53 +261,53 @@ export default function AdminOrganizationsPage() {
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' : org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800' 'bg-purple-100 text-purple-800'
}`}> }`}>
{getTypeLabel(org.type)} {org.type.replace('_', ' ')}
</span> </span>
</div> </div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${ <span className={`px-2 py-1 text-xs font-semibold rounded-full ${
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}> }`}>
{org.isActive ? t('active') : t('inactive')} {org.isActive ? 'Active' : 'Inactive'}
</span> </span>
</div> </div>
<div className="space-y-2 text-sm text-gray-600 mb-4"> <div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && ( {org.scac && (
<div> <div>
<span className="font-medium">{t('scac')}:</span> {org.scac} <span className="font-medium">SCAC:</span> {org.scac}
</div> </div>
)} )}
{org.siren && ( {org.siren && (
<div> <div>
<span className="font-medium">{t('siren')}:</span> {org.siren} <span className="font-medium">SIREN:</span> {org.siren}
</div> </div>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{t('siret')}:</span> <span className="font-medium">SIRET:</span>
{org.siret ? ( {org.siret ? (
<> <>
<span>{org.siret}</span> <span>{org.siret}</span>
{org.siretVerified ? ( {org.siretVerified ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800"> <span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
{t('verified')} Verifie
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"> <span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
{t('notVerified')} Non verifie
</span> </span>
)} )}
</> </>
) : ( ) : (
<span className="text-gray-400">{t('notProvided')}</span> <span className="text-gray-400">Non renseigne</span>
)} )}
</div> </div>
{org.contact_email && ( {org.contact_email && (
<div> <div>
<span className="font-medium">{t('email')}:</span> {org.contact_email} <span className="font-medium">Email:</span> {org.contact_email}
</div> </div>
)} )}
<div> <div>
<span className="font-medium">{t('location')}:</span> {org.address.city}, {org.address.country} <span className="font-medium">Location:</span> {org.address.city}, {org.address.country}
</div> </div>
</div> </div>
@ -325,7 +317,7 @@ export default function AdminOrganizationsPage() {
onClick={() => openEditModal(org)} onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium" className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
> >
{t('edit')} Edit
</button> </button>
{org.siret && !org.siretVerified && ( {org.siret && !org.siretVerified && (
<button <button
@ -333,7 +325,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
{verifyingId === org.id ? t('verifying') : t('verifyApi')} {verifyingId === org.id ? '...' : 'Verifier API'}
</button> </button>
)} )}
</div> </div>
@ -345,7 +337,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
{t('approveSiret')} Approuver SIRET
</button> </button>
) : ( ) : (
<button <button
@ -353,7 +345,7 @@ export default function AdminOrganizationsPage() {
disabled={verifyingId === org.id} disabled={verifyingId === org.id}
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50" className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
> >
{t('rejectSiret')} Rejeter SIRET
</button> </button>
)} )}
</div> </div>
@ -368,12 +360,12 @@ export default function AdminOrganizationsPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4"> <h2 className="text-xl font-bold mb-4">
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')} {showCreateModal ? 'Create New Organization' : 'Edit Organization'}
</h2> </h2>
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4"> <form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.name')}</label> <label className="block text-sm font-medium text-gray-700">Organization Name *</label>
<input <input
type="text" type="text"
required required
@ -384,21 +376,21 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.type')}</label> <label className="block text-sm font-medium text-gray-700">Type *</label>
<select <select
value={formData.type} value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })} onChange={e => setFormData({ ...formData, type: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" 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"
> >
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option> <option value="FREIGHT_FORWARDER">Freight Forwarder</option>
<option value="CARRIER">{t('types.CARRIER')}</option> <option value="CARRIER">Carrier</option>
<option value="SHIPPER">{t('types.SHIPPER')}</option> <option value="SHIPPER">Shipper</option>
</select> </select>
</div> </div>
{formData.type === 'CARRIER' && ( {formData.type === 'CARRIER' && (
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.scacLabel')}</label> <label className="block text-sm font-medium text-gray-700">SCAC Code *</label>
<input <input
type="text" type="text"
required={formData.type === 'CARRIER'} required={formData.type === 'CARRIER'}
@ -411,7 +403,7 @@ export default function AdminOrganizationsPage() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.sirenLabel')}</label> <label className="block text-sm font-medium text-gray-700">SIREN</label>
<input <input
type="text" type="text"
maxLength={9} maxLength={9}
@ -422,19 +414,19 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.siretLabel')}</label> <label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
<input <input
type="text" type="text"
maxLength={14} maxLength={14}
value={formData.siret} value={formData.siret}
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })} onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none" className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
placeholder={t('modal.siretPlaceholder')} placeholder="12345678901234"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.eoriLabel')}</label> <label className="block text-sm font-medium text-gray-700">EORI</label>
<input <input
type="text" type="text"
value={formData.eori} value={formData.eori}
@ -444,7 +436,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactPhone')}</label> <label className="block text-sm font-medium text-gray-700">Contact Phone</label>
<input <input
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
@ -454,7 +446,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.contactEmail')}</label> <label className="block text-sm font-medium text-gray-700">Contact Email</label>
<input <input
type="email" type="email"
value={formData.contact_email} value={formData.contact_email}
@ -464,7 +456,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.street')}</label> <label className="block text-sm font-medium text-gray-700">Street Address *</label>
<input <input
type="text" type="text"
required required
@ -478,7 +470,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.city')}</label> <label className="block text-sm font-medium text-gray-700">City *</label>
<input <input
type="text" type="text"
required required
@ -492,7 +484,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.postalCode')}</label> <label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<input <input
type="text" type="text"
required required
@ -506,7 +498,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.state')}</label> <label className="block text-sm font-medium text-gray-700">State/Region</label>
<input <input
type="text" type="text"
value={formData.address.state} value={formData.address.state}
@ -519,7 +511,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.country')}</label> <label className="block text-sm font-medium text-gray-700">Country *</label>
<input <input
type="text" type="text"
required required
@ -534,7 +526,7 @@ export default function AdminOrganizationsPage() {
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">{t('modal.logoUrl')}</label> <label className="block text-sm font-medium text-gray-700">Logo URL</label>
<input <input
type="url" type="url"
value={formData.logoUrl} value={formData.logoUrl}
@ -555,13 +547,13 @@ export default function AdminOrganizationsPage() {
}} }}
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.cancel')} Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
{showCreateModal ? t('modal.create') : t('modal.update')} {showCreateModal ? 'Create' : 'Update'}
</button> </button>
</div> </div>
</form> </form>

View File

@ -1,12 +1,10 @@
'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 { interface User {
id: string; id: string;
@ -26,8 +24,6 @@ interface Organization {
} }
export default function AdminUsersPage() { export default function AdminUsersPage() {
const t = useTranslations('dashboard.admin.users');
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -54,6 +50,7 @@ export default function AdminUsersPage() {
password: '', password: '',
}); });
// Fetch users and organizations
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, []); }, []);
@ -70,7 +67,7 @@ export default function AdminUsersPage() {
setOrganizations(orgsResponse.organizations || []); setOrganizations(orgsResponse.organizations || []);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || t('loadError')); setError(err.message || 'Failed to load data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -84,7 +81,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false); setShowCreateModal(false);
resetForm(); resetForm();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('createError')); alert(err.message || 'Failed to create user');
} }
}; };
@ -104,7 +101,7 @@ export default function AdminUsersPage() {
setSelectedUser(null); setSelectedUser(null);
resetForm(); resetForm();
} catch (err: any) { } catch (err: any) {
alert(err.message || t('updateError')); alert(err.message || 'Failed to update user');
} }
}; };
@ -117,7 +114,7 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
setSelectedUser(null); setSelectedUser(null);
} catch (err: any) { } catch (err: any) {
alert(err.message || t('deleteError')); alert(err.message || 'Failed to delete user');
} }
}; };
@ -150,20 +147,12 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(true); setShowDeleteConfirm(true);
}; };
const getRoleLabel = (role: string) => {
const allowed = ['USER', 'MANAGER', 'ADMIN', 'VIEWER'];
if (allowed.includes(role)) {
return t(`roles.${role}` as any);
}
return role;
};
if (loading) { if (loading) {
return ( return (
<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">Loading users...</p>
</div> </div>
</div> </div>
); );
@ -171,18 +160,21 @@ export default function AdminUsersPage() {
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">User Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all users in the system
</p>
</div>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
> >
{t('create')} + Create User
</button> </button>
} </div>
/>
{/* Error Message */} {/* Error Message */}
{error && ( {error && (
@ -197,22 +189,22 @@ export default function AdminUsersPage() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.user')} User
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.email')} Email
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.role')} Role
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.organization')} Organization
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')} Status
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')} Actions
</th> </th>
</tr> </tr>
</thead> </thead>
@ -233,7 +225,7 @@ export default function AdminUsersPage() {
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' : user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800' 'bg-gray-100 text-gray-800'
}`}> }`}>
{getRoleLabel(user.role)} {user.role}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@ -243,7 +235,7 @@ export default function AdminUsersPage() {
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${ <span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}> }`}>
{user.isActive ? t('active') : t('inactive')} {user.isActive ? 'Active' : 'Inactive'}
</span> </span>
</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 text-right text-sm font-medium space-x-2">
@ -251,13 +243,13 @@ export default function AdminUsersPage() {
onClick={() => openEditModal(user)} onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900" className="text-blue-600 hover:text-blue-900"
> >
{t('edit')} Edit
</button> </button>
<button <button
onClick={() => openDeleteConfirm(user)} onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900" className="text-red-600 hover:text-red-900"
> >
{t('delete')} Delete
</button> </button>
</td> </td>
</tr> </tr>
@ -270,10 +262,10 @@ export default function AdminUsersPage() {
{showCreateModal && ( {showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2> <h2 className="text-xl font-bold mb-4">Create New User</h2>
<form onSubmit={handleCreate} className="space-y-4"> <form onSubmit={handleCreate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.email')}</label> <label className="block text-sm font-medium text-gray-700">Email</label>
<input <input
type="email" type="email"
required required
@ -283,7 +275,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> <label className="block text-sm font-medium text-gray-700">First Name</label>
<input <input
type="text" type="text"
required required
@ -293,7 +285,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> <label className="block text-sm font-medium text-gray-700">Last Name</label>
<input <input
type="text" type="text"
required required
@ -303,27 +295,27 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label> <label className="block text-sm font-medium text-gray-700">Role</label>
<select <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} 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"
> >
<option value="USER">{t('roles.USER')}</option> <option value="USER">User</option>
<option value="MANAGER">{t('roles.MANAGER')}</option> <option value="MANAGER">Manager</option>
<option value="ADMIN">{t('roles.ADMIN')}</option> <option value="ADMIN">Admin</option>
<option value="VIEWER">{t('roles.VIEWER')}</option> <option value="VIEWER">Viewer</option>
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.organization')}</label> <label className="block text-sm font-medium text-gray-700">Organization</label>
<select <select
required required
value={formData.organizationId} value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })} 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" 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"
> >
<option value="">{t('modal.selectOrganization')}</option> <option value="">Select Organization</option>
{organizations.map(org => ( {organizations.map(org => (
<option key={org.id} value={org.id}> <option key={org.id} value={org.id}>
{org.name} {org.name}
@ -333,7 +325,7 @@ export default function AdminUsersPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.password')} Password (leave empty for auto-generated)
</label> </label>
<input <input
type="password" type="password"
@ -351,13 +343,13 @@ export default function AdminUsersPage() {
}} }}
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.cancel')} Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
{t('modal.create')} Create
</button> </button>
</div> </div>
</form> </form>
@ -369,10 +361,10 @@ export default function AdminUsersPage() {
{showEditModal && selectedUser && ( {showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2> <h2 className="text-xl font-bold mb-4">Edit User</h2>
<form onSubmit={handleUpdate} className="space-y-4"> <form onSubmit={handleUpdate} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.emailReadOnly')}</label> <label className="block text-sm font-medium text-gray-700">Email (read-only)</label>
<input <input
type="email" type="email"
disabled disabled
@ -381,7 +373,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.firstName')}</label> <label className="block text-sm font-medium text-gray-700">First Name</label>
<input <input
type="text" type="text"
required required
@ -391,7 +383,7 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.lastName')}</label> <label className="block text-sm font-medium text-gray-700">Last Name</label>
<input <input
type="text" type="text"
required required
@ -401,16 +393,16 @@ export default function AdminUsersPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label> <label className="block text-sm font-medium text-gray-700">Role</label>
<select <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} 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"
> >
<option value="USER">{t('roles.USER')}</option> <option value="USER">User</option>
<option value="MANAGER">{t('roles.MANAGER')}</option> <option value="MANAGER">Manager</option>
<option value="ADMIN">{t('roles.ADMIN')}</option> <option value="ADMIN">Admin</option>
<option value="VIEWER">{t('roles.VIEWER')}</option> <option value="VIEWER">Viewer</option>
</select> </select>
</div> </div>
<div className="flex justify-end space-x-2 pt-4"> <div className="flex justify-end space-x-2 pt-4">
@ -423,13 +415,13 @@ export default function AdminUsersPage() {
}} }}
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.cancel')} Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
> >
{t('modal.update')} Update
</button> </button>
</div> </div>
</form> </form>
@ -441,9 +433,10 @@ export default function AdminUsersPage() {
{showDeleteConfirm && selectedUser && ( {showDeleteConfirm && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2> <h2 className="text-xl font-bold mb-4 text-red-600">Confirm Delete</h2>
<p className="text-gray-700 mb-6"> <p className="text-gray-700 mb-6">
{t('deleteConfirm.message', { firstName: selectedUser.firstName, lastName: selectedUser.lastName })} Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
This action cannot be undone.
</p> </p>
<div className="flex justify-end space-x-2"> <div className="flex justify-end space-x-2">
<button <button
@ -453,13 +446,13 @@ export default function AdminUsersPage() {
}} }}
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('deleteConfirm.cancel')} Cancel
</button> </button>
<button <button
onClick={handleDelete} onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700" className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
> >
{t('deleteConfirm.confirm')} Delete
</button> </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